@wcstack/worker
v1.13.0
Published
Declarative Web Worker component for Web Components. Framework-agnostic Dedicated Worker primitive via wc-bindable-protocol.
Maintainers
Readme
@wcstack/worker
@wcstack/worker is a headless Web Worker component for the wcstack ecosystem.
It is not a visual UI widget.
It is an async primitive node that turns a Dedicated Worker into reactive state — the same way @wcstack/fetch turns a network request into reactive state and @wcstack/websocket turns a socket into reactive state.
<wcs-worker> owns a background thread and exposes its message-passing surface through the wc-bindable token protocol:
- post (
state → element) via the command-token protocol —command.post: $command.run - message (
element → state) via the event-token protocol —eventToken.message: onResult
With @wcstack/state, <wcs-worker> can be bound directly through path contracts:
- input surface:
src,type,name,manual,keep-alive,restart-on-error,max-restarts,restart-interval - command surface:
start,post,terminate - output state surface:
message,error,running
This means offloading work to a worker thread can be expressed declaratively in HTML, without writing new Worker(), postMessage(), onmessage listeners, or teardown glue in your UI layer.
@wcstack/worker follows the CSBC (Core / Shell / Binding Contract) architecture:
- Core (
WorkerCore) owns the worker lifecycle, posting, structured-clone receipt, error handling, and opt-in restart-on-error - Shell (
<wcs-worker>) connects that state to DOM attributes, lifecycle, and declarative commands - Binding Contract (
static wcBindable) declares observableproperties, writableinputs, and callablecommands
Why this exists
A Worker is, like fetch or WebSocket, an asynchronous source of values — but it also owns a resource (a background thread). Imperatively it requires constructing the worker, wiring message / messageerror / error listeners, and terminating on teardown.
@wcstack/worker moves that logic into a reusable component and exposes the result as bindable state. A computed result coming back from a worker becomes a state transition, not imperative callback wiring.
Bus-style, not RPC.
postis fire-and-forget and results arrive onmessage; there is no built-in request/response correlation. If you need to match a reply to a specific request, include a correlation id in your payload and have the worker echo it back (or awaitmessagefor the next value when only one request is in flight).
Structured clone, no JSON round-trip. Payloads ride the browser's structured clone (symmetrical with
@wcstack/broadcast, deliberately unlike<wcs-ws>which sends over a text wire). Post objects directly; the worker receives a copy. A non-cloneable payload (a function, a DOM node) surfaces aDataCloneErrorthrough theerrorproperty rather than throwing.
ESM by default. The worker is created with
{ type: "module" }unless you settype="classic".
Install
npm install @wcstack/workerQuick Start
1. Run a job and read the result
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<script type="module" src="https://esm.run/@wcstack/worker/auto"></script>
<wcs-state>
<script type="module">
export default { result: null };
</script>
</wcs-state>
<wcs-worker id="job" src="./compute.js" data-wcs="message: result"></wcs-worker>
<!-- Optional DOM triggering: click posts the resolved text to the worker -->
<input id="n" value="42" />
<button data-worker-target="job" data-worker-from="#n">Run</button>
<p data-wcs="textContent: result"></p>data-worker-text posts a literal string; data-worker-from posts the value (or textContent) of the element matched by the selector.
2. post (command-token) + result (event-token)
The duality in one element: post is wired from a command-token, and an incoming message is received via an event-token.
<wcs-state>
<script type="module">
export default {
input: 10,
output: null,
$commandTokens: ["run"],
$eventTokens: ["onResult"],
compute() {
this.$command.run.emit(this.input); // state → worker
},
$on: {
onResult: (state, event) => { // worker → state
state.output = event.detail;
}
}
};
</script>
</wcs-state>
<wcs-worker src="./compute.js" data-wcs="
command.post: $command.run;
eventToken.message: onResult
"></wcs-worker>
<button data-wcs="onclick: compute">Compute</button>
<p data-wcs="textContent: output"></p>Attributes / Inputs
| Attribute | Type | Default | Description |
| ------------------ | ------- | ---------- | --------------------------------------------------------------------------- |
| src | string | "" | The worker script URL. Changing it terminates the old worker and spawns the new script. |
| type | string | "module" | "module" (ESM) or "classic". |
| name | string | "" | Optional worker name, passed to the Worker constructor name option (aids DevTools / error identification). Applied at spawn time — see the note on type below. |
| manual | boolean | false | Do not spawn automatically on connect or on src change. Call start() instead. |
| keep-alive | boolean | false | Do not terminate the worker on disconnect — it outlives the element. Ownership transfers to you: call terminate() to free the thread. |
| restart-on-error | boolean | false | Re-spawn a fresh worker after an uncaught error inside the worker script. |
| max-restarts | number | Infinity | Upper bound on the cumulative number of automatic restarts over the worker's lifetime (not consecutive crashes — the counter is not reset by a stable run). Reset only by a fresh start() / src change. |
| restart-interval | number | 0 | Delay in ms before an automatic restart. |
DOM trigger attributes (autoTrigger, post-on-click)
| Attribute | On | Description |
| -------------------- | -------------- | --------------------------------------------------------------------- |
| data-worker-target | trigger button | Id of the <wcs-worker> to drive. |
| data-worker-text | trigger button | Literal text to post (takes precedence; empty string is valid). |
| data-worker-from | trigger button | CSS selector; posts the matched element's value (or textContent). |
The DOM trigger always posts a string — the literal data-worker-text, or the resolved element's value / textContent. It is a convenience for simple text payloads and intentionally does not parse, coerce, or structure the value. To send structured-clone data (objects, typed arrays, transferables), drive post via the command-token protocol (command.post: $command.run) or call element.post(data, transfer?) imperatively.
Observable Properties (outputs)
| Property | Event | Description |
| --------- | ----------------------------- | ------------------------------------------------------------------------------------ |
| message | wcs-worker:message | The last value posted back by the worker (structured-clone copy). Re-fires on every message, even when the value is unchanged. |
| error | wcs-worker:error | Normalized { name, message, filename?, lineno?, colno? } — DataCloneError (non-cloneable post), DataError (a worker message could not be deserialized), InvalidStateError (post with no running worker), a script Error (uncaught error in the worker, with location), or a spawn failure (bad URL / CSP / unsupported). |
| running | wcs-worker:running-changed | true while a worker is spawned and not yet terminated. |
Commands
| Command | Description |
| ----------- | ------------------------------------------------------------------------------------------- |
| start | Spawn the worker from the src attribute (terminates any previously-spawned worker; idempotent on the same src). |
| post | Post a structured-cloneable value to the worker (never rejects — failures go to error). The headless WorkerCore.post(data, transfer?) also accepts a transfer list. |
| terminate | Terminate the worker (idempotent). |
State-driven invocation uses the command-token protocol:
<wcs-worker src="./compute.js" data-wcs="command.post: $command.run"></wcs-worker>Notes & limitations
- Bus-style message model. No request/response correlation is built in;
postis fire-and-forget and replies arrive onmessage. An RPC-stylerequest(data): Promiseis a possible future addition for imperative use. - No "ready" signal. A worker accepts
postMessageimmediately (the platform queues messages until the script loads), and there is no standard "script loaded" event.runningmeans "spawned and not terminated", not "ready to serve requests". If you need a true ready signal, have the workerposta ready message on startup and observe it viamessage. keep-alivetransfers ownership. Withoutkeep-alive, the worker is terminated on disconnect (like<wcs-ws>/<wcs-broadcast>close). Withkeep-alive, the worker survives disconnect and you become responsible for callingterminate()— otherwise the thread leaks. A consequence of this ownership transfer: with bothkeep-aliveandrestart-on-error, a restart pending at disconnect (an error that scheduled arestart-intervaltimer) is not cancelled and will fire after the element leaves the DOM, re-spawning a fresh worker on the now-detached element. This is intentional —keep-alivemeans the lifecycle is yours past disconnect — but it means the cleanest way to stop akeep-aliveworker is an explicitterminate(), which also clears any pending restart.restart-on-erroris opt-in and bounded. An uncaught error inside the worker does not auto-terminate it on the platform. Whenrestart-on-erroris set, a fresh worker is spawned afterrestart-intervalms, up tomax-restartstimes (mirrors<wcs-ws>reconnect bounding). The restart counter is cumulative over the worker's lifetime: it counts the total number of restarts since the laststart()and is not reset by a period of stable operation. Somax-restartsbounds total restarts, not consecutive crashes — a worker that recovers and later fails again still draws down the same budget. The counter resets only on a freshstart()(or asrcchange, which callsstart()). Once the budget is exhausted, callingstart()again with the samesrcis idempotent and will not re-spawn — callterminate()thenstart()(or changesrc) to reset the counter and spawn fresh. Setmax-restartswhen using it — the defaults (max-restarts="Infinity",restart-interval="0") mean a worker that throws immediately on load will re-spawn in a tightsetTimeout(0)loop, floodingwcs-worker:error/wcs-worker:running-changedand starving the main thread. A small positiverestart-intervaland a finitemax-restartsbound the blast radius.- A restart does not replay
poststate. Each restart callsnew Worker(src)and produces a fresh process with no memory of prior messages; the Core does not re-send any earlierposts. If a worker needs initialization state to function (a config message, a transferred port), it must request or rebuild it on startup (e.g.posta ready signal and have the page reply), because restart-on-error will not re-deliver it. srcis observed;type/nameare applied at spawn. Changing thesrcattribute while connected (and notmanual) terminates the old worker and spawns the new script; only a non-empty new value triggers the switch.typeandnameare read at spawn time and are not inobservedAttributes— changing them on an already-running worker has no effect until the next spawn (asrcchange, or aterminate()+start()). Likewise, re-callingstart()with the samesrcis idempotent and ignores changed options.- Transferables are an escape hatch.
transfer(ArrayBuffer ownership, MessagePort) cannot be expressed throughdata-wcsdata wiring. Use the imperativeelement.post(data, transfer)(orWorkerCore.post(data, transfer)); the declarative layer carries structured-clone data only. - Silent failure handling (zero-log). Consistent with wcstack's zero-dependency philosophy,
<wcs-worker>never logs or throws for runtime failures. A bad script URL, a CSPworker-srcblock, a non-cloneable post, a deserialization failure, and an uncaught worker error are surfaced only through theerrorproperty /wcs-worker:errorevent —post()returns and never rejects. Binderrorto observe and react. srcruns as code — trust it. Thesrcvalue is passed straight tonew Worker(src), which executes the script with the page's privileges; the tag does not validate or sandbox the origin. Only pointsrcat scripts you trust (treat it like a<script src>), and prefer aContent-Security-Policywith an explicitworker-srcallowlist to constrain where workers may load from — especially ifsrccan be influenced by data binding.- Dedicated Worker only. SharedWorker and Worklets are out of scope for this tag.
Headless usage (WorkerCore)
The Core has no DOM dependency beyond the global Worker and can be used directly with bind() from @wc-bindable/core:
import { WorkerCore } from "@wcstack/worker";
const core = new WorkerCore();
core.addEventListener("wcs-worker:message", (e) => {
console.log((e as CustomEvent).detail); // the value posted back by the worker
});
core.start("./compute.js");
core.post({ task: "sum", values: [1, 2, 3] });
// transfer an ArrayBuffer (ownership moves to the worker)
const buf = new ArrayBuffer(1024);
core.post(buf, [buf]);
// ...later
core.terminate();License
MIT
