# vue-worker-kit — AI Reference Type-safe Web Worker composables for Vue 3: `useWorker()` (single lazily- created worker), `createWorkerPool()`/`useWorkerPool()` (a pool of workers for many small tasks), `useWorkerComputed()` (reactive `computed()`-like value recalculated in a worker), `useSharedWorker()` (one worker shared across tabs via `SharedWorker`), `defineWorkerHandler()` for the worker side, and a runtime activity-monitor/debug panel. Real Web Workers, a small hand-rolled request/response protocol (not Comlink-style proxying, no RPC dependency), zero runtime dependencies beyond Vue, SSR-safe. Version 0.2.4. Vue-only — **no Nuxt module, no Vite plugin, no CLI** despite the "nuxt" package.json keyword (see gotcha 10). This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs). For human-readable narrative docs, see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/vue-worker-kit/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/vue-worker-kit/guide/overview - GitHub: https://github.com/macrulezru/vue-worker-kit - npm: https://www.npmjs.com/package/vue-worker-kit Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map | Import path | Contains | |---|---| | `vue-worker-kit` | `useWorker`, `createWorkerPool`, `useWorkerPool`, `useWorkerComputed`, `defineWorkerHandler`, `attachWorkerProtocol`, `createCacheKey`, `WorkerError`, `WorkerUnavailableError`, every shared type. **Not** `useSharedWorker` — see below. | | `vue-worker-kit/worker` | `defineWorkerHandler` (also re-exported from root — this is the dedicated worker-side entry, for importing inside a `.worker.ts` file without pulling in main-thread composables). | | `vue-worker-kit/pool` | `createWorkerPool`, `useWorkerPool` (also re-exported from root). | | `vue-worker-kit/computed` | `useWorkerComputed` (also re-exported from root). | | `vue-worker-kit/shared` | `useSharedWorker` — **the only place it's exported from; not available via the root `"."` entry**, unlike every other composable. | | `vue-worker-kit/devtools` | `createWorkerActivityMonitor`, `WorkerActivityPanel` — runtime debug UI, not a build-time tool, not a `@vue/devtools-api` browser-extension integration. | No plugin/install step — every export is a plain function/component, import what you use. `peerDependencies`: only `vue: ^3.4.0`. This is the package's **only** runtime dependency of any kind. Worker scripts are bundled with **plain Vite worker-import syntax** — the package provides no build-time tooling of its own: ```ts const worker = () => new Worker(new URL('./compute.worker.ts', import.meta.url), { type: 'module' }) ``` Whether that emits a separate chunk or an inlined blob depends entirely on the *consuming app's* own Vite config (`worker.format`, `assetsInlineLimit`) — vue-worker-kit is agnostic to it. --- ## 2. Architecture — the wire protocol A small hand-rolled JSON message protocol (`src/protocol.ts`), not Comlink-style method-proxying. Correlated by an incrementing numeric `id`, one id-space **per `WorkerClient` instance** (i.e. per worker/port, not global): ```ts // main → worker type MainToWorkerMessage = | { type: 'run'; id: number; input: unknown } | { type: 'cancel'; id: number; reason?: unknown } | { type: 'disconnect' } // SharedWorker only // worker → main type WorkerToMainMessage = | { type: 'result'; id: number; output: unknown } | { type: 'error'; id: number; error: { name: string; message: string; stack?: string } } | { type: 'progress'; id: number; value: number } | { type: 'chunk'; id: number; chunk: unknown } | { type: 'portCount'; count: number } // SharedWorker only, un-correlated (no id) ``` - **Serialization**: standard structured clone via `postMessage` — no custom encoding. `RunOptions.transfer` (main→worker) and `ctx.transfer(...)` (worker→main) support zero-copy `Transferable`s both directions. - A reactive `input` (a `ref`/`reactive` value passed straight from a component) is unwrapped via **`toRaw()`** before `postMessage` — **shallow only**: the top-level value is unwrapped, but a *nested* reactive object inside a plain object/array is not. - A non-cloneable `input` makes `worker.postMessage(...)` throw synchronously; caught and turned into `WorkerError('Failed to clone data for the worker (structured clone failure)', { name: 'DataCloneError' })`. - Error propagation: the worker wraps the handler call in `Promise.resolve().then(() => handler(...)).catch(...)` — **always at least one microtask deferred**, even for a synchronous handler; a thrown value is `serializeError()`'d to plain `{name,message,stack}` data and sent as an `error` message. Main thread turns it back into a `WorkerError` via `workerErrorFromSerialized`. - A worker-level **crash** (native `Worker.onerror`, e.g. a syntax error in the worker file) rejects **every currently pending request** on that client at once with a generic `WorkerError('Worker crashed')` — see gotcha 6 for what does (and doesn't) happen to the worker instance afterward. - `WorkerError.cause` is a synthetic `Error` created **synchronously at the `run()`/`send()` call site** (before crossing into the worker), so its `.stack` points at your actual calling code; `.workerStack` (a separate field) carries the original worker-side stack. Both are visible together when logged. --- ## 3. `useWorker(factory, options?)` — root entry ```ts interface UseWorkerOptions { idleTimeout?: number | false // default 30_000ms; worker self-terminates after this much idle time; false disables it retries?: number // default 0; NOT applied to AbortError or WorkerUnavailableError retryDelay?: (attempt: number) => number // default: immediate retry, no delay hardCancelOnAbort?: boolean // default false — true = terminate + transparently recreate the worker on abort, instead of cooperative ctx.signal cache?: { cache?: 'lru'; maxCacheSize?: number /* default 50 */ } streaming?: boolean // default false — gates whether `chunks` exists on the return object at all } interface UseWorkerReturn { run(input: In, options?: { transfer?: Transferable[]; signal?: AbortSignal }): Promise isRunning: ComputedRef progress: ShallowRef // 0..1, reset to 0 at the start of every run() (not a cache hit — see below) error: ShallowRef cancel(): void warmup(): Promise // pre-creates the worker without running a task chunks?: ShallowRef // present ONLY when streaming: true } function useWorker(factory: () => Worker, options?: UseWorkerOptions): UseWorkerReturn<...> ``` `TModule` is meant to be `typeof import('./x.worker')` — `run()`'s input/output types are read off it via `WorkerModuleInput`/ `WorkerModuleOutput`, conditional types keyed on the phantom `WorkerHandlerModule` that `defineWorkerHandler()` returns (type-only — no worker code reaches the main bundle just by referencing the type). ### 3.1 Lazy creation, idle timeout, and what "terminate" actually reaches The `Worker` instance is created **lazily** — `factory()` runs only inside the first `run()`/`warmup()` call, not at `useWorker()` call time. `idleTimeout`'s timer is (re)scheduled in every `run()`'s `finally` block, but only actually starts counting once `activeCount === 0`. When it fires (or on `hardCancelOnAbort`, or on effect-scope disposal), the package's internal `terminate()` runs: `client?.dispose(...)` (rejects all pending requests) **then** `worker?.terminate()`, then nulls both out so the next `run()` creates a fresh worker. **There is no public `terminate()` method on `UseWorkerReturn`.** Only `cancel()` (aborts in-flight runs, doesn't touch the worker instance itself) and `warmup()` are exposed. Real termination only happens via: the idle timer, `hardCancelOnAbort: true` on an abort, or automatic `onScopeDispose` cleanup — which is **only registered if `useWorker()` is called inside an active Vue effect scope** (`getCurrentScope()` truthy, e.g. a component's `setup()`). Call it from a plain module-level function or outside any effect scope and the worker is **never auto-terminated**. ### 3.2 Caching Cache key = `JSON.stringify(input)`, computed **before** any special handling. A cache hit `return`s the cached value **immediately** — bypassing `activeCount`/`progress`/`error` updates entirely and never emitting `taskStart`/`taskEnd` on the activity bus (a cache hit is invisible to `createWorkerActivityMonitor`). LRU eviction: insertion-order `Map`, oldest entries removed once `cache.size > maxCacheSize` (default 50); a cache *read* also re-inserts the key (moves it to "most recently used" position). **Gotcha**: `JSON.stringify` degrades non-JSON-friendly inputs silently — an `ArrayBuffer`, a `Map`, functions, or `undefined` object fields don't round-trip meaningfully (`{}` for most of these), which can produce **colliding cache keys for genuinely different inputs**. There is no cache-bypass for binary/transferable inputs even though `RunOptions.transfer` (a very binary-data-oriented feature) coexists with `cache`. ### 3.3 `cancel()` scope ```ts function cancel(): void { for (const controller of internalControllers) controller.abort() } ``` `internalControllers` is a `Set` of every `AbortController` `run()` created **internally** (i.e. calls that did **not** pass their own `options.signal`). Calling `cancel()` aborts **all** of them at once — not just the most recent call. Several concurrent `run()` calls without individual signals are cancelled together by one `cancel()`. ### 3.4 Retry loop ``` for (;;) { try { return await runOnce(...) } catch (err) { if (isAbortError(err) || err instanceof WorkerUnavailableError) throw err // never retried if (attempt < retries) { attempt++; if (retryDelay) await delay(retryDelay(attempt)); continue } // exhausted: wrap in WorkerError (unless already one), set error.value, emit taskError, throw } } ``` `AbortError` and `WorkerUnavailableError` are **structurally excluded** from retry and from ever being wrapped into a generic `WorkerError` — an `instanceof WorkerUnavailableError` check downstream (e.g. to detect "running during SSR") keeps working through a retry-enabled `run()`. ### 3.5 Streaming `chunks` only exists on the returned object when `options.streaming === true`. **If `streaming` is left `false` (the default) but the worker handler calls `ctx.reportChunk()` anyway, the chunk messages are silently dropped** on the main thread — no error, nothing to observe; the `onChunk` callback simply was never registered with `WorkerClient.send()`. --- ## 4. `createWorkerPool(factory, options?)` / `useWorkerPool(...)` (`/pool`) ```ts interface WorkerPoolOptions { size?: number // default navigator.hardwareConcurrency; falls back to 4 if navigator/that field is unavailable (SSR) } interface WorkerPoolStats { busy: number; idle: number; queued: number } interface WorkerMapOptions { concurrency?: number // default: pool size signal?: AbortSignal // one signal for the whole map() call transfer?: (item: T) => Transferable[] } interface WorkerPool { run(input: In, options?: RunOptions): Promise map(items: In[], options?: WorkerMapOptions): Promise readonly stats: ComputedRef readonly size: number terminate(): void warmup(): Promise // eagerly creates all `size` slots (createWorkerPool alone does NOT; slots are created lazily as tasks arrive) } ``` - Workers ("slots") are created **lazily, one at a time**, only as queued tasks actually need one, up to `size` — unless `warmup()` is called first. - `map(items, options)`: a **work-stealing pull queue** — spawns `Math.min(concurrency, items.length)` internal loops pulling from a shared `nextIndex` counter, not a static partition. Results still land at `results[originalIndex]` (order-preserving) even though completion order can differ. - `terminate()` sets an internal `terminated` flag **before** clearing slots/rejecting the queue — a task that was already in flight and settles asynchronously *after* `terminate()` checks this flag in its own `finish()` to avoid double-decrementing `busyCount` (a real bug, fixed pre-1.0, guarded this way ever since). - `useWorkerPool` (vs. plain `createWorkerPool`) adds `onScopeDispose` → `terminate()`, same opt-in-by-effect-scope pattern as `useWorker`. No idle-timeout concept exists for a pool at all — only explicit `terminate()`. **Gotcha — no progress/chunks support at all**: `pool.run()`/`pool.map()` call `slot.client.send(task.input, task.transfer, undefined)` — **no `onChunk` argument, `onProgress` explicitly `undefined`**. A worker handler that calls `ctx.reportProgress()`/`ctx.reportChunk()` still posts those messages (it doesn't know it's running under a pool), but nothing on the main thread is listening — they're silently discarded. There is no `progress`/`chunks` field anywhere on `WorkerPool`. The **exact same handler file** that streams/reports progress correctly under `useWorker`/`useSharedWorker` loses that capability entirely when run through a pool — this is not documented in the README's feature list. --- ## 5. `useWorkerComputed(factory, source, options?)` (`/computed`) ```ts interface UseWorkerComputedOptions { debounce?: number } // default 0ms interface WorkerComputedResult { readonly value: Out | undefined; readonly isRunning: boolean; readonly error: WorkerError | null } function useWorkerComputed(factory: () => Worker, source: () => WorkerModuleInput, options?: UseWorkerComputedOptions): WorkerComputedResult> ``` Wraps a plain internal `useWorker(factory)` called with **no options** — `idleTimeout`/`retries`/`cache`/`hardCancelOnAbort`/`streaming` are all fixed at their `useWorker` defaults, and `useWorkerComputed` provides **no way to forward any of them**. `watch(source, schedule, { immediate: true })` — the first run fires right away, but is **still subject to `debounce`** (the immediate trigger calls the same debounced `schedule()`, it just means the watcher itself doesn't wait for a *change* before scheduling). **Stale-run handling is result-discarding, not execution-cancelling**: each new `fire()` calls `previousController?.abort()` (a **cooperative** abort — only trips `ctx.signal` inside the worker handler) and bumps a `generation` counter; when a run's promise resolves, its result is applied only `if (myGeneration === generation)`, otherwise dropped silently. Since the underlying `useWorker()` call here never sets `hardCancelOnAbort`, **a superseded computation keeps running to completion in the worker** (burning CPU) unless the handler itself checks `ctx.signal.aborted` cooperatively — only its *result* is ignored. Return value: `reactive({ value, isRunning, error })` — despite the TS type declaring all three fields `readonly`, this is a `reactive()` wrapper (unwraps the inner `shallowRef`), so **`result.value = x` would actually succeed at runtime** and corrupt internal state; `readonly` here is compile-time only. **Cannot be used with `createWorkerActivityMonitor()`** — its return value is never passed through `attachActivityBus()`, and `WorkerActivitySource`'s type union (`WorkerPool | UseWorkerReturn | UseSharedWorkerReturn`) excludes `WorkerComputedResult` entirely. **SSR note**: because the internal `watch(..., { immediate: true })` fires `run()` right at setup, `useWorkerComputed()` **attempts a worker call during SSR** unless guarded — it doesn't crash (the rejection is caught internally and surfaces via `error`), but every SSR render attempts-and-immediately-rejects a call unless client-only-gated. This differs from `useWorker()`/`useWorkerPool()`/`useSharedWorker()`, none of which touch a worker until you explicitly call `run()`/`warmup()`/`connect()`. --- ## 6. `useSharedWorker(factory, options?)` (`/shared` — root entry does NOT re-export this) ```ts interface UseSharedWorkerOptions { retries?: number retryDelay?: (attempt: number) => number cache?: UseWorkerCacheOptions streaming?: boolean // NOTE: no idleTimeout, no hardCancelOnAbort — see below } interface UseSharedWorkerReturn { run(input: In, options?: RunOptions): Promise connect(): void // idempotent; run() calls it lazily if you haven't disconnect(): void // closes THIS TAB'S port only — other tabs stay connected portCount: Ref // best-effort, see below isRunning: ComputedRef progress: ShallowRef error: ShallowRef cancel(): void chunks?: ShallowRef // present only if streaming: true } function useSharedWorker(factory: () => SharedWorker, options?: UseSharedWorkerOptions): UseSharedWorkerReturn<...> ``` Reuses one worker across every same-origin tab that connects, via `new SharedWorker(...)` + the platform's own `onconnect`/`MessagePort`. The **same** worker-side file (`defineWorkerHandler()`) works for both `useWorker()` and `useSharedWorker()` without modification — the worker side detects `DedicatedWorkerGlobalScope` vs. `SharedWorkerGlobalScope` at runtime. - **Not supported in Safari iOS or Chrome Android** — no `SharedWorker` constructor exists there at all (not an SSR-only concern); `connect()`/ `run()` throw `WorkerUnavailableError('SharedWorker is not supported in this environment')`. - **No `idleTimeout`, no `hardCancelOnAbort`**, and no public `terminate()`-equivalent that actually tears down the shared worker — by design/platform constraint: one tab can't unilaterally kill a worker other tabs still use. `disconnect()` is the real, cooperative teardown for *this tab only*: posts a `disconnect` message, `client.dispose(...)` (rejects this tab's pending requests), `port.close()`, resets `portCount.value = 0`. - **`portCount` is best-effort** — only decremented via the cooperative `disconnect` message; there is no platform-level notification when a tab's port disappears without sending one (a crash, a force-close), so a count that only ever grew from those would be a lie — the package simply never decrements for them (documented directly in the source). - A cooperative `disconnect()` **also aborts** that tab's own in-flight worker-side request (`WorkerProtocolHandle.abortAll()`) before closing the port — so the handler stops as soon as it next checks `ctx.signal`, instead of computing to completion for a client that already gave up (this exact gap was a real bug, fixed). - The internal `WorkerLike` adapter's `terminate` field is a **no-op** (`() => {}`) — only `disconnect()` does anything. - `onScopeDispose` (if inside an effect scope) calls `disconnect()` automatically, same opt-in pattern as `useWorker`'s `terminate()`. --- ## 7. `defineWorkerHandler(handler)` — the worker-side file (`/worker`, also root) ```ts interface WorkerContext { readonly signal: AbortSignal reportProgress(value: number): void // throttled — see below transfer(...transferables: Transferable[]): void // accumulates across multiple calls; objects needn't be part of the return value reportChunk(chunk: unknown): void } type WorkerHandlerFn = (input: In, ctx: WorkerContext) => Out | Promise function defineWorkerHandler(handler: WorkerHandlerFn): WorkerHandlerModule // phantom-typed marker, {} at runtime function attachWorkerProtocol(handler, scope?): { abortAll(reason?: unknown): void } function attachSharedWorkerProtocol(handler, scope?): void ``` ```ts // compute.worker.ts import { defineWorkerHandler } from 'vue-worker-kit/worker' export default defineWorkerHandler((input: MyInput, ctx): MyOutput => { ctx.reportProgress(0.5) if (ctx.signal.aborted) throw new DOMException('aborted', 'AbortError') return doWork(input) }) ``` - `defineWorkerHandler()` **only wires up a message loop when evaluated inside a real `DedicatedWorkerGlobalScope`/`SharedWorkerGlobalScope`** (feature-detected via `self instanceof`). Importing the `.worker.ts` file from the main bundle by accident is completely inert — returns `{}`, does nothing, doesn't throw. - The handler is always invoked via `Promise.resolve().then(() => handler(...))` — **deferred at least one microtask**, even for a fully synchronous handler; both a synchronous throw and an async rejection are caught identically via `.catch()`. - **`ctx.reportProgress` is throttled to one message per 50ms** (`Date.now()`-based, dropped — not queued — if called again inside the window). **Immediately after the handler resolves**, an *unthrottled* `{ type: 'progress', value: 1 }` is always sent before the `result` message — `progress` is guaranteed to reach exactly `1` on completion even if the handler's own last checkpoint got swallowed by the throttle window. This happens **unconditionally**, even if the handler never called `reportProgress` at all. - Message dispatch is a strict allowlist: only `'run'`/`'cancel'` are handled by `attachWorkerProtocol`; anything else is ignored outright (previously, pre-0.2.0, anything not `'cancel'` was misread as `'run'` — fixed). - `attachSharedWorkerProtocol`: each connecting port gets its **own, independent** `attachWorkerProtocol()` instance — its own `controllers` map, its own request-id space — so two tabs both using request id `1` never collide or cross-cancel. --- ## 8. `/devtools` — `createWorkerActivityMonitor` + `` ```ts type WorkerActivitySource = WorkerPool | UseWorkerReturn | UseSharedWorkerReturn // NOTE: WorkerComputedResult (useWorkerComputed's return) is NOT part of this union — see section 5. interface WorkerActivityError { name: string; message: string; at: number } // `at` = Date.now() when the ERROR MESSAGE was received, not when it actually occurred worker-side interface WorkerActivitySnapshot { busy: number; idle: number; queued: number; averageTaskMs: number | null; recentErrors: WorkerActivityError[] } interface WorkerActivityMonitorOptions { maxErrors?: number /* default 20 */; maxSamples?: number /* default 50 */ } interface WorkerActivityMonitor { readonly snapshot: ComputedRef; clearErrors(): void; dispose(): void } function createWorkerActivityMonitor(source: WorkerActivitySource, options?: WorkerActivityMonitorOptions): WorkerActivityMonitor ``` ```vue ``` - **Not a `@vue/devtools-api` browser-extension integration** — a plain runtime debug panel, deliberately, to keep the core dependency-free. - Driven by the internal `ActivityBus` pub/sub (`taskStart`/`taskEnd`/ `taskError` events) — **no polling**. - For a single `useWorker()`/`useSharedWorker()` source (not a pool), `busy`/`idle` are **synthesized** from `isRunning` as `1`/`0` — never more than 1 busy; `queued` is always `0` (no queue concept for a single worker). - `averageTaskMs`: simple rolling mean over the last `maxSamples` (default 50) `taskEnd` durations (push + shift + reduce), recomputed on every `taskEnd`. - `recentErrors`: capped at `maxErrors` (default 20), **newest-first**. - **`dispose()` only unsubscribes — it does not reset `snapshot`**. The `computed()` keeps returning its last value if read again after `dispose()`, it just stops updating. - If the source somehow lacks the internal activity-bus symbol (see gotcha 14), `bus` is `undefined` and the monitor silently never receives events rather than throwing. - `` uses **inline styles** (not `