Host, connection, and typing
Build the worker runtime with createWorkerHost, connect to it with connectWorker, and get the whole thing typed from one export.
@mmstack/workernpm
#The worker host
createWorkerHost is the entire worker runtime. It has no Angular DI, only signals and plain functions. Call it once at the top of your worker entry file. By default it serves the worker's own self scope, so your code never touches self or postMessage.
// app/demo.worker.ts
import { store } from '@mmstack/primitives';
import { createWorkerHost, workerStoreContext } from '@mmstack/worker/host';
const todos = store<Todo[]>([], workerStoreContext());
const filter = store({ q: '' }, workerStoreContext()); // same context
const host = createWorkerHost({
stores: { todos, filter }, // writable stores the worker owns
published: { visible }, // derived signals mirrored read-only
tasks: { search, index }, // callable functions
});
export type AppWorker = typeof host;workerStoreContext() returns the one store context for the worker. Pass it to every store you create there so they share proxy identity and cleanup. It is the worker's version of providedIn: 'root', scoped to the thread because the /host entry only loads inside a worker. Do not use it on the main thread.
#Connecting
connectWorker spawns the worker and runs the handshake. You pass a factory that returns the worker, not the worker itself, which is what keeps it safe on the server and puts the bundler-visible literal in your app code.
import { connectWorker } from '@mmstack/worker';
import type { AppWorker } from './demo.worker';
const worker = connectWorker<AppWorker>(
() => new Worker(new URL('./demo.worker', import.meta.url), { type: 'module' }),
);
worker.connected(); // Signal<boolean>, true after the handshake
worker.manifest(); // Signal<{ hostId, stores, published, tasks } | null>
worker.runTask('search', 'foo'); // Promise<Result>, typed from the tasks
worker.destroy(); The new Worker(new URL('./x.worker', import.meta.url), { type: 'module' }) form must be written literally: a string literal path, and import.meta.url as the second argument. This is the one shape the Angular application builder, webpack 5, and Vite all recognize and turn into a separate worker bundle. A computed path silently opts out.
#Typing from the contract
Export the host type from your worker file and pass it to connectWorker. Every store key, value type, task signature, and whether a subtree is writable is then inferred.
const worker = connectWorker<AppWorker>(spawn);
workerStore(worker, 'todos'); // Todo[], writable (owned store)
workerStore(worker, 'visible'); // read-only, no write() (published)
worker.runTask('search', 'foo'); // typed input and result
worker.runTask('unknown', 1); // compile error: not a task For an untyped connection you can still set the value type by hand with workerStore<Todo[]>(worker, 'todos').
#Server-side rendering
On the server connectWorker does not spawn. connected() stays false, replicas hold their default value, and runTask rejects. The subtree renders its connecting state and resolves once the worker connects during client hydration. No guard needed on your side.
// nothing to guard: on the server this renders "connecting…"
// and resolves after hydration
readonly connected = this.worker.connected;
readonly todos = workerStore(this.worker, 'todos', { defaultValue: [] });#Crash handling
With restart: 'auto' (the default) a crashed worker respawns with exponential backoff, re-handshakes, and every live replica re-subscribes. Pass restart: 'manual' to surface the disconnect instead of recovering.
const worker = connectWorker<AppWorker>(spawn, { restart: 'auto' });
// or 'manual' to stop and surface the disconnect via connected()