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()