@enclosurejs/compute
v1.1.0
Published
> [!IMPORTANT] > This is a **universal module** for running computation off the main thread. It wraps the standard Web Worker API into three typed primitives — Channel, Caller, and Pool — with `Disposable` lifecycle, `AbortSignal` cancellation, and `Trans
Readme
@enclosurejs/compute — Off-thread computation via Web Workers
[!IMPORTANT] This is a universal module for running computation off the main thread. It wraps the standard Web Worker API into three typed primitives — Channel, Caller, and Pool — with
Disposablelifecycle,AbortSignalcancellation, andTransferablesupport. Works identically in browser, Electron renderer, and Tauri webview. Zero platform abstraction needed — Web Workers are the same everywhere.
The Problem
Heavy computation on the main thread blocks UI: tag evaluation engines, data transformers, image processors, parsers, and indexers all need to run off-thread. The raw Worker API is low-level — no typing on messages, no structured cleanup, no error isolation, no pool management, no timeout/cancel. Every project reinvents the same new Worker() + postMessage + onmessage + terminate boilerplate with ad-hoc switch routing inside the worker.
@enclosurejs/compute solves this with three primitives:
- Channel — stateful, long-lived, bidirectional streaming (evaluator, polling, pipeline)
- Caller — one-shot request/response with timeout and abort (parsing, hashing, reports)
- Pool — N workers with a shared task queue for parallel batch processing (image resize, indexing)
All three implement Disposable, support Transferable zero-copy, and work on every Enclosure target platform without any platform-specific code.
Architecture
┌─────────────────── UI Thread ───────────────────┐
│ │
│ import { createChannel } from '@enclosurejs/compute'│
│ │
│ channel.send({ type: 'updated', data }) │
│ │ │
│ │ postMessage (structured clone) │
│ │ ──────────── or ────────────── │
│ │ postMessage (transfer, zero-copy) │
│ ▼ │
│ channel.messages ← ReadableStream<TOut> │
│ │
└───────────┬───────────────────────────────────────┘
│
┌───────────┴───────────────────────────────────────┐
│ Web Worker │
│ │
│ import { handleMessages, reply } │
│ from '@enclosurejs/compute/worker' │
│ │
│ handleMessages({ │
│ init(data) { /* ... */ reply(result) }, │
│ updated(data) { /* ... */ reply(result) }, │
│ }) │
│ │
└───────────────────────────────────────────────────┘No platform bridge, no IPC serialization — direct postMessage in the same JS runtime.
Why not a platform capability?
Web Workers are a standard Web API — they work identically in every environment that runs JavaScript (browser, Electron renderer, Tauri webview, Capacitor webview, mobile webview). Unlike ShellService (which needs child_process on Electron, plugin-shell on Tauri), there is nothing platform-specific to abstract. This is the same reasoning that led to rejecting WebSocketService and CryptoService — standard APIs don't need a platform bridge.
Performance characteristics
| Data path | Copies | Transfer | SharedArrayBuffer | Latency 1KB | Latency 10MB |
| ------------------------ | ------ | --------- | ----------------- | ----------- | ------------ |
| postMessage (clone) | 1 | — | — | ~0.01ms | ~30ms |
| postMessage (transfer) | 0 | zero-copy | — | ~0.01ms | ~0.01ms |
| SharedArrayBuffer | 0 | — | shared memory | ~0.01ms | ~0.01ms |
All paths are available on all six target platforms (web, Electron win/linux/mac, Tauri win/linux/mac/android/ios).
Quick Start
Channel — bidirectional, long-lived
import { createChannel } from '@enclosurejs/compute';
interface EvalInput {
type: string;
data: unknown;
}
interface EvalOutput {
period: number;
result: unknown[];
}
const channel = createChannel<EvalInput, EvalOutput>({
id: 'evaluator',
src: new URL('./evaluator-worker.js', import.meta.url),
});
channel.send({ type: 'init', data: tasks });
channel.send({ type: 'updated', data: values });
const reader = channel.messages.getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
updateUI(value.result);
}
channel.dispose();Caller — one-shot request/response
import { createCaller } from '@enclosurejs/compute';
const parser = createCaller<ArrayBuffer, ParsedData>({
id: 'csv-parser',
src: new URL('./csv-worker.js', import.meta.url),
type: 'module',
});
const result = await parser.invoke(csvBuffer, {
transfer: [csvBuffer],
timeout: 5000,
signal: abortController.signal,
});Pool — parallel batch processing
import { createPool } from '@enclosurejs/compute';
const pool = createPool<ImageData, Uint8Array>(
{ id: 'image-resize', src: '/workers/resize.js' },
navigator.hardwareConcurrency,
);
const thumbnail = await pool.submit(largeImage);
// Yields results in completion order (not submission order)
for await (const resized of pool.map(images)) {
appendToGallery(resized);
}
await pool.drain();
pool.dispose();Worker-side helpers (optional)
// evaluator-worker.ts
import { handleMessages, reply } from '@enclosurejs/compute/worker';
handleMessages({
init(data) {
const tasks = data as Task[];
// ... process ...
reply({ period: 1.2, result });
},
updated(data) {
// ... evaluate ...
reply({ period: 0.3, result });
},
});The worker does not have to use these helpers — raw self.onmessage / self.postMessage works fine. The helpers add typed routing and transfer-aware reply().
API
Host-side (@enclosurejs/compute)
| Export | Kind | Purpose |
| ---------------------- | ------- | ---------------------------------------------------- |
| createComputeService | factory | Returns a ComputeService with all three primitives |
| createChannel | factory | Stateful bidirectional worker channel |
| createCaller | factory | One-shot request/response worker |
| createPool | factory | N-worker pool with task queue |
Worker-side (@enclosurejs/compute/worker)
| Export | Kind | Purpose |
| ---------------- | -------- | ------------------------------------------------------------- |
| handleMessages | function | Typed message router ({ type: handler } → self.onmessage) |
| reply | function | Transfer-aware self.postMessage wrapper |
WorkerDescriptor
| Field | Type | Required | Description |
| ------ | ----------------------- | -------- | --------------------------------------------------- |
| id | string | Yes | Identifier for debugging (e.g. "evaluator") |
| src | string \| URL | Yes | Worker script path/URL, passed to new Worker(src) |
| type | 'module' \| 'classic' | No | Script type. Defaults to 'classic' |
ComputeChannel<TIn, TOut>
Extends Disposable.
| Member | Type | Description |
| -------------------------- | ---------------------- | --------------------------------------------------------- |
| id | string | Auto-generated identifier ("evaluator:1") |
| alive | boolean | true while the worker is running |
| send(message, transfer?) | method | Post a message; throws CoreError (TERMINATED) if dead |
| messages | ReadableStream<TOut> | Errors on worker onerror; closes on terminate |
| onError(handler) | method | Subscribe to worker errors → Disposable |
| terminate() | method | Kill the worker, close the stream. Idempotent |
| dispose() | method | Alias for terminate() |
ComputeCaller<TIn, TOut>
Extends Disposable.
| Member | Type | Description |
| ------------------------- | ------ | ----------------------------------------------------------------------------------- |
| invoke(input, options?) | method | Spawns a dedicated worker per call. Rejects with CoreError on error/abort/timeout |
| dispose() | method | Rejects any in-flight invoke and terminates its worker |
CallOptions
| Field | Type | Description |
| ---------- | ---------------- | ----------------------------------------------------- |
| signal | AbortSignal | Cancel the pending call (rejects with code ABORTED) |
| timeout | number | Milliseconds before auto-rejection (code TIMEOUT) |
| transfer | Transferable[] | Zero-copy buffer transfer |
ComputePool<TIn, TOut>
Extends Disposable.
| Member | Type | Description |
| ------------------------- | -------- | ------------------------------------------------------------------------- |
| size | number | Current worker count |
| pending | number | Tasks waiting in queue (not yet assigned to a worker) |
| submit(input, options?) | method | Submit one task, resolve on completion |
| map(inputs, options?) | method | Completion-order AsyncGenerator<TOut>. Throws on first task failure |
| drain() | method | Wait until all tasks complete |
| resize(count) | method | Add/remove workers (minimum 1). Busy workers finish before retiring |
| dispose() | method | Terminate all workers, reject queued and in-flight tasks with CoreError |
WorkerMessage<T> (worker-side)
| Field | Type | Description |
| ------ | ------------ | ------------------------ |
| type | T (string) | Message type for routing |
| data | unknown | Payload (optional) |
Configuration
Zero configuration. No config files, no environment variables. Pass a WorkerDescriptor to any factory — that's it.
Pool size defaults to navigator.hardwareConcurrency (typically CPU core count) or 4 as fallback.
Types Exported
Types other packages and application code depend on:
| Type | Used by |
| --------------------------- | ---------------------------------------- |
| ComputeService | Application code creating workers via DI |
| ComputeChannel<TIn, TOut> | Long-lived worker consumers |
| ComputeCaller<TIn, TOut> | One-shot worker consumers |
| ComputePool<TIn, TOut> | Batch processing consumers |
| WorkerDescriptor | Worker configuration |
| CallOptions | Caller and pool consumers |
| WorkerMessage<T> | Worker scripts using handleMessages |
| MessageHandlers<T> | Worker scripts using handleMessages |
Entrypoint separation keeps worker-side types out of host bundles:
| Import path | Contains | Runs in |
| --------------------------- | ------------------------------ | ------------- |
| @enclosurejs/compute | Channel, Caller, Pool, Service | UI thread |
| @enclosurejs/compute/worker | handleMessages, reply | Worker thread |
Safety
Error Model
All errors thrown or rejected by this package are CoreError instances (domain 'compute'):
| Code | When |
| -------------- | ----------------------------------------------------- |
| TERMINATED | send() on a dead channel |
| DISPOSED | invoke()/submit() after dispose |
| SUPERSEDED | A new invoke() on a Caller aborted the previous one |
| WORKER_ERROR | Worker fires onerror |
| ABORTED | AbortSignal triggered |
| TIMEOUT | Response not received within deadline |
Lifecycle Safety
- All three primitives implement
Disposable— add toDisposableGroupfor automatic cleanup channel.aliveand disposed state prevent use-after-terminate errorssend()on a terminated channel throws immediatelyinvoke()on a disposed caller rejects immediatelysubmit()on a disposed pool rejects immediatelydispose()on a caller rejects any in-flight promise and terminates the workerdispose()on a pool rejects all in-flight and queued tasks
Error Isolation
- Worker crashes are caught via
onerror— the host thread never crashes - Channel: errors forwarded to both
onErrorhandlers and themessagesReadableStream - Pool: idle worker
onerrortriggers automatic respawn to maintain pool size - Throwing
onErrorhandlers are isolated — one bad handler doesn't affect others
Cancellation Safety
AbortSignalsupport on Caller and Pool — Caller terminates the worker on abort; Pool frees the worker back to the idle set (reused, not terminated)- Timeout support — Caller terminates the worker on timeout; Pool frees the worker (same as abort)
- Timer cleanup on normal completion — no dangling
setTimeout
Transfer Safety
Transferablearrays are forwarded topostMessagefor zero-copy handoff- Empty transfer arrays are handled (no unnecessary structured clone overhead)
Caveats
Caller: one invoke at a time
ComputeCaller allows only one in-flight invocation. Calling invoke() a second time before the first settles automatically aborts the previous call — the previous promise rejects with SUPERSEDED and its worker is terminated. No resources are orphaned. If you need concurrent invocations, use a Pool instead.
Pool: map() error behavior
map() yields results in completion order and throws on the first task failure, terminating the generator. Remaining in-flight tasks are not automatically cancelled — they continue executing in the pool. If you need per-task error handling without stopping iteration, use submit() in a loop with individual try/catch.
Pool: dispatch() and array mutation
dispatch() iterates the workers array with for...of. Recursive calls (from onAbort → finishTask → dispatch()) create a new iterator over the potentially-mutated array. This is safe today because finishTask only removes retiring workers (which are already busy=true → skipped by the outer iterator), and queue.shift() guards against double-dispatch. But modifications to the dispatch logic should be tested carefully for iterator invalidation.
Benchmarks
Not applicable in Node.js. Web Workers are a browser API — new Worker() requires a DOM-capable environment (browser, Electron renderer, Tauri webview). The unit tests use mock workers (vi.stubGlobal) for deterministic verification of lifecycle, error handling, abort/timeout, and pool scheduling logic. Real-world performance depends on:
postMessagestructured clone overhead (~30ms for 10MB data)Transferablezero-copy transfer (~0.01ms regardless of size)- Worker startup time (~5–50ms depending on script complexity)
- Pool saturation —
navigator.hardwareConcurrencyworkers can process N tasks in parallel
For production benchmarking, use the browser's Performance API inside your worker scripts.
Bundle Size
| Output | File | Size |
| ------------ | ------------- | -------- |
| Runtime (JS) | index.js | 11.68 KB |
| | worker.js | 454 B |
| Types (DTS) | index.d.ts | 3.72 KB |
| | worker.d.ts | 782 B |
| Total JS | | 12.13 KB |
| Total | | 16.64 KB |
index.js contains all host-side primitives (Channel, Caller, Pool, ComputeService). worker.js contains only handleMessages + reply — shipped separately because it runs in the Worker thread. Single external dependency (@enclosurejs/core) is workspace-only and marked as external in the build.
Quality
| Metric | Value |
| --------------------- | ---------------------------------------------------------------------- |
| Unit tests | 72 (all pass) |
| Test files | 5 (channel, caller, pool, compute, worker) |
| Source files | 7 (types, channel, caller, pool, compute, worker, index) |
| Dependencies | 1 (@enclosurejs/core — workspace) |
| External dependencies | 0 (devDependencies only: tsup) |
| Coverage thresholds | statements >= 90%, branches >= 85%, functions >= 95%, lines >= 90% |
Quality Layers
Layer 1: STATIC ANALYSIS (every commit)
tsc --noEmit strict mode, zero errors
eslint ESLint 9 flat config, zero warnings
prettier --check formatting
Layer 2: UNIT TESTS (every commit)
72 tests channel (16), caller (17), pool (23), compute (6), worker (10)
covers lifecycle, errors, abort, timeout, transfer, dispose,
stream error propagation, idle worker respawn, unordered map,
in-flight dispose, supersede, handler isolation
v8 coverage statements >= 90%, branches >= 85%, functions >= 95%, lines >= 90%
Layer 3: BENCHMARKS
N/A browser-only API, not benchmarkable in Node.js
Layer 4: PACKAGE HEALTH
1 workspace dep @enclosurejs/core (types + Deferred, externalized in build)
tsup build ESM + DTS output, separate entrypoints (host + worker)File Structure
packages/compute/
├── src/
│ ├── index.ts Barrel: createComputeService, createChannel, createCaller, createPool
│ ├── types.ts All interfaces (ComputeService, Channel, Caller, Pool, Descriptor)
│ ├── compute.ts createComputeService() factory (shared channel id counter)
│ ├── channel.ts createChannel() — bidirectional Worker + ReadableStream
│ ├── caller.ts createCaller() — one-shot with in-flight dispose
│ ├── pool.ts createPool() — N workers + FIFO queue + auto-respawn
│ ├── worker.ts handleMessages(), reply() — worker-side helpers
│ └── __tests__/
│ ├── channel.test.ts 16 tests — send, stream, error→stream, handler isolation, lifecycle
│ ├── caller.test.ts 17 tests — invoke, errors, abort, timeout, idempotency, supersede, in-flight dispose
│ ├── pool.test.ts 23 tests — submit, drain, dispose, resize, map (unordered), respawn, abort, timeout
│ ├── compute.test.ts 6 tests — service factory, shared/independent id counters
│ └── worker.test.ts 10 tests — routing, unknown types, empty map, reply, transfer
├── .prettierignore
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── README.mdLicense
MIT
