@mmstack/worker 21.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mmstack-worker-host.mjs","sources":["../../../../packages/worker/host/src/lib/microtask-driver.ts","../../../../packages/worker/host/src/lib/create-worker-host.ts","../../../../packages/worker/host/src/lib/worker-store-context.ts","../../../../packages/worker/host/src/index.ts","../../../../packages/worker/host/src/mmstack-worker-host.ts"],"sourcesContent":["import { createWatch } from '@angular/core/primitives/signals';\nimport type { OpLogDriver } from '@mmstack/primitives';\n\n/**\n * An injector-free {@link OpLogDriver} for `@mmstack/primitives` `opLog`. Schedules the emission\n * reaction on the microtask queue via `createWatch` (Angular's renderer-independent watch\n * primitive) instead of an `effect`, so an opLog can run where there is no Angular injector: the\n * worker side of the worker-graph, whose store has no application tick driving it.\n *\n * This lives in `@mmstack/worker/host` rather than in primitives on purpose. `createWatch` is a\n * lower-tier signals-primitives export; keeping it out of `@mmstack/primitives` lets that package\n * back-port to older Angular majors that may not export it. The worker package tracks a newer\n * Angular where `createWatch` is available.\n *\n * Batching is per-microtask rather than per-app-tick; the worker protocol orders by\n * `(origin, version)`, never tick alignment, so that is safe.\n */\nexport function microtaskOpLogDriver(): OpLogDriver {\n return (run) => {\n let scheduled = false;\n // run reads the source (tracking) and flushes; it never writes signals, so allowSignalWrites=false\n const watch = createWatch(\n () => run(),\n (w) => {\n if (scheduled) return;\n scheduled = true;\n queueMicrotask(() => {\n scheduled = false;\n w.run(); // no-op if the watch is not dirty\n });\n },\n false,\n );\n watch.notify(); // bootstrap: schedule the first run to establish the source dependency\n return { destroy: () => watch.destroy() };\n };\n}\n","import {\n isDevMode,\n untracked,\n type ResourceStatus,\n type Signal,\n type WritableSignal,\n} from '@angular/core';\nimport { createWatch } from '@angular/core/primitives/signals';\nimport { applyOps, opLog, type OpLog } from '@mmstack/primitives';\nimport {\n generateId,\n PROTO_VERSION,\n serializeError,\n type HasSchema,\n type SignalValueOf,\n type WorkerEnvelope,\n type WorkerPortLike,\n type WorkerSchema,\n} from '@mmstack/worker/protocol';\nimport { microtaskOpLogDriver } from './microtask-driver';\n\n/** A named unit of compute a worker exposes (rung 1). `ctx.signal` aborts when the caller cancels. */\nexport type WorkerTaskHandler<I = any, O = any> = (\n input: I,\n ctx: { readonly signal: AbortSignal },\n) => O | Promise<O>;\n\n/** A published derivation: a plain signal, or a status-bearing one (a `latest()`) for pending propagation. */\nexport type PublishedSource<T> =\n | Signal<T>\n | (Signal<T | undefined> & { readonly status: Signal<ResourceStatus> });\n\nexport type CreateWorkerHostOptions = {\n /**\n * Store subtrees this worker OWNS — the single writer. Keys are the wire identifiers. Typed as\n * the plain writable-signal interface (any copy-on-write `WritableSignal<object>` qualifies, per\n * `opLog`); a concrete `store<T>` satisfies it without the mapped-store variance friction that\n * `WritableSignalStore<any>` would introduce.\n */\n readonly stores?: Record<string, WritableSignal<any>>;\n /**\n * Read-only DERIVED subtrees the worker PUBLISHES (rung 3) — a `Signal<T>` (e.g. a `computed` or\n * `projection`) or a status-bearing async derivation (a `latest()`). Main-thread `workerStore`s\n * mirror them like owned stores but cannot write; a status-bearing entry also propagates its\n * pending/error to the replica, so an in-flight worker computation shows as pending on the main\n * thread.\n */\n readonly published?: Record<string, PublishedSource<any>>;\n /** Named tasks callable from the main thread (rung 1). */\n readonly tasks?: Record<string, WorkerTaskHandler>;\n /**\n * Transport to serve on. Defaults to the worker global scope (`self`) when running in a real\n * Worker, so `my.worker.ts` never touches `self`. Pass an explicit port for tests (a\n * `MessageChannel` port) or a SharedWorker fan-in.\n */\n readonly port?: WorkerPortLike;\n};\n\nexport type WorkerHost<M extends WorkerSchema = WorkerSchema> = HasSchema<M> & {\n /** This host's transport identity — the `origin` of every store batch it emits. */\n readonly hostId: string;\n /** Serve an additional transport (multi-client / tests). Returns a disconnect handle. */\n connect(port: WorkerPortLike): () => void;\n /**\n * Synchronously emit any pending owned-store changes to subscribers NOW, instead of waiting for\n * the microtask driver. Makes the mirror deterministic (tests call it before asserting) and is\n * the honest settle point before applying a routed write (phase 4). No-op when nothing is pending.\n */\n flush(): void;\n /** Stop every owned opLog and drop all connections. */\n dispose(): void;\n};\n\ntype Connection = {\n readonly port: WorkerPortLike;\n clientId: string | null;\n readonly stores: Set<string>;\n /** In-flight named-task runs for THIS client, keyed by its runId (client runIds aren't global). */\n readonly taskRuns: Map<number, AbortController>;\n};\n\ntype Subtree = {\n /** Untracked read of the current root value (for snapshots). */\n readonly read: () => unknown;\n readonly log: OpLog<any>;\n /** The version of the last batch emitted — a fresh subscriber's snapshot carries it. */\n version: number;\n /** The writable store for an OWNED subtree; `null` for a PUBLISHED (read-only) one. */\n readonly writable: WritableSignal<any> | null;\n};\n\n/** Maps an Angular ResourceStatus to the wire status; only the states the protocol carries. */\nfunction toRemoteStatus(\n s: ResourceStatus,\n): 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' {\n return s === 'idle' || s === 'loading' || s === 'reloading' || s === 'error'\n ? s\n : 'resolved';\n}\n\n/**\n * Dev-only guard: a store value that is not structured-clonable (a function, a class instance, an\n * `opaque()` value) throws a bare `DataCloneError` from `postMessage` with no context. Catch it\n * before it hits the wire and name the store, so the fix is obvious. Stripped in production.\n */\nfunction devAssertCloneable(value: unknown, what: string): void {\n if (!isDevMode()) return;\n try {\n structuredClone(value);\n } catch (err) {\n throw new Error(\n `[@mmstack/worker] ${what} holds a value that cannot be sent across the worker boundary. ` +\n `Synced state must be structured-clonable: functions, class instances, and opaque() values ` +\n `do not serialize. Keep it to plain JSON-like data.`,\n { cause: err },\n );\n }\n}\n\nfunction isWorkerGlobal(g: unknown): g is WorkerPortLike {\n const scope = (globalThis as { WorkerGlobalScope?: unknown })\n .WorkerGlobalScope;\n return (\n typeof scope === 'function' &&\n g instanceof (scope as new () => unknown) &&\n typeof (g as { postMessage?: unknown }).postMessage === 'function'\n );\n}\n\n/**\n * The worker-side runtime: owns store subtrees, hosts tasks, and mirrors owned state to every\n * connected client as op batches. Plain functions + signals — NO Angular DI (there is none in a\n * worker); each owned store is observed by a real {@link opLog} driven off the microtask queue.\n *\n * ```ts\n * // my.worker.ts\n * const todos = store<Todo[]>([], workerStoreContext());\n * createWorkerHost({ stores: { todos } }); // serves `self` by default\n * ```\n */\nexport function createWorkerHost<\n S extends Record<string, WritableSignal<any>> = Record<string, never>,\n P extends Record<string, PublishedSource<any>> = Record<string, never>,\n T extends Record<string, WorkerTaskHandler> = Record<string, never>,\n>(options: {\n readonly stores?: S;\n readonly published?: P;\n readonly tasks?: T;\n readonly port?: WorkerPortLike;\n}): WorkerHost<{\n readonly stores: { [K in keyof S]: SignalValueOf<S[K]> };\n readonly published: { [K in keyof P]: SignalValueOf<P[K]> };\n readonly tasks: T;\n}> {\n const hostId = generateId();\n // the S/P/T generics drive the RETURN type only; the body works over loose records\n const sources = (options.stores ?? {}) as Record<string, WritableSignal<any>>;\n const publishedSources = (options.published ?? {}) as Record<\n string,\n PublishedSource<any>\n >;\n const tasks = (options.tasks ?? {}) as Record<string, WorkerTaskHandler>;\n const taskNames = Object.keys(tasks);\n const storeKeys = Object.keys(sources);\n const publishedKeys = Object.keys(publishedSources);\n\n const subtrees = new Map<string, Subtree>();\n const connections = new Set<Connection>();\n // current wire status of each status-bearing published entry, read at subscribe time so a late\n // subscriber sees an in-flight computation as pending instead of waiting for the next transition\n const publishedStatus = new Map<\n string,\n () => 'idle' | 'loading' | 'reloading' | 'resolved' | 'error'\n >();\n const statusWatches: { destroy(): void }[] = [];\n\n const fanout = (store: string, message: WorkerEnvelope) => {\n for (const conn of Array.from(connections))\n if (conn.stores.has(store)) conn.port.postMessage(message);\n };\n\n // observe a signal's value → snapshot/ops. Owned stores also route writes; published are read-only.\n // Applied remote writes ride this same emission, echo-free (the owner is the single sequencer).\n const observe = (\n key: string,\n src: Signal<unknown>,\n writable: WritableSignal<any> | null,\n ): void => {\n const entry: Subtree = {\n read: () => untracked(src),\n log: null as unknown as OpLog<any>,\n version: 0,\n writable,\n };\n (entry as { log: OpLog<any> }).log = opLog(\n // a published (read-only) signal is only ever READ by the log — apply() is never called on it\n src as unknown as WritableSignal<any>,\n { driver: microtaskOpLogDriver(), origin: hostId },\n );\n entry.log.subscribe((batch) => {\n entry.version = batch.version;\n devAssertCloneable(batch, `store '${key}' update`);\n fanout(key, { type: 'store:ops', store: key, batch });\n });\n subtrees.set(key, entry);\n };\n\n for (const key of storeKeys) observe(key, sources[key], sources[key]);\n\n for (const key of publishedKeys) {\n const src = publishedSources[key];\n observe(key, src as Signal<unknown>, null);\n // rung 3: a status-bearing derivation (a latest()) propagates its pending/error to replicas\n const statusSig = (src as { status?: Signal<ResourceStatus> }).status;\n if (statusSig) {\n publishedStatus.set(key, () => toRemoteStatus(untracked(statusSig)));\n let scheduled = false;\n let last: ResourceStatus | null = null;\n const w = createWatch(\n () => {\n const s = statusSig();\n if (s === last) return;\n last = s;\n fanout(key, {\n type: 'store:status',\n store: key,\n status: toRemoteStatus(s),\n });\n },\n (watch) => {\n if (scheduled) return;\n scheduled = true;\n queueMicrotask(() => {\n scheduled = false;\n watch.run();\n });\n },\n false,\n );\n w.notify();\n statusWatches.push(w);\n }\n }\n\n const runTask = (\n conn: Connection,\n runId: number,\n handler: WorkerTaskHandler,\n input: unknown,\n ): void => {\n const ac = new AbortController();\n conn.taskRuns.set(runId, ac);\n Promise.resolve()\n .then(() => handler(input, { signal: ac.signal }))\n .then(\n (value) => {\n if (ac.signal.aborted)\n conn.port.postMessage({ type: 'task:aborted', runId });\n else conn.port.postMessage({ type: 'task:ok', runId, value });\n },\n (err) => {\n if (ac.signal.aborted)\n conn.port.postMessage({ type: 'task:aborted', runId });\n else\n conn.port.postMessage({\n type: 'task:error',\n runId,\n error: serializeError(err),\n });\n },\n )\n .finally(() => {\n if (conn.taskRuns.get(runId) === ac) conn.taskRuns.delete(runId);\n });\n };\n\n const handle = (conn: Connection, msg: WorkerEnvelope): void => {\n switch (msg.type) {\n case 'hello': {\n conn.clientId = msg.clientId;\n conn.port.postMessage({\n type: 'ready',\n proto: PROTO_VERSION,\n hostId,\n stores: storeKeys,\n published: publishedKeys,\n tasks: taskNames,\n });\n return;\n }\n case 'store:subscribe': {\n const sub = subtrees.get(msg.store);\n if (!sub) return; // unknown store — silently ignore (a client typo, not fatal)\n // synchronous: read snapshot + version and register the client in one turn — no batch can\n // interleave in a single-threaded worker, so every later op carries version > this one\n conn.stores.add(msg.store);\n const snapshot = sub.read();\n devAssertCloneable(snapshot, `store '${msg.store}' snapshot`);\n conn.port.postMessage({\n type: 'store:snapshot',\n store: msg.store,\n version: sub.version,\n value: snapshot,\n });\n // a status-bearing published entry also reports its CURRENT status, so a subscriber\n // arriving mid-computation shows pending now, not on the next transition\n const currentStatus = publishedStatus.get(msg.store);\n if (currentStatus)\n conn.port.postMessage({\n type: 'store:status',\n store: msg.store,\n status: currentStatus(),\n });\n return;\n }\n case 'store:unsubscribe': {\n conn.stores.delete(msg.store);\n return;\n }\n case 'task:run': {\n const handler = tasks[msg.task];\n if (!handler) {\n conn.port.postMessage({\n type: 'task:error',\n runId: msg.runId,\n error: serializeError(\n new Error(`[@mmstack/worker] unknown task: ${msg.task}`),\n ),\n });\n return;\n }\n runTask(conn, msg.runId, handler, msg.input);\n return;\n }\n case 'task:abort': {\n conn.taskRuns.get(msg.runId)?.abort();\n conn.taskRuns.delete(msg.runId);\n return;\n }\n case 'store:write': {\n const sub = subtrees.get(msg.store);\n if (!sub || !sub.writable) {\n conn.port.postMessage({\n type: 'store:write:error',\n store: msg.store,\n writeId: msg.writeId,\n error: serializeError(\n new Error(\n sub\n ? `[@mmstack/worker] store is read-only (published): ${msg.store}`\n : `[@mmstack/worker] unknown store: ${msg.store}`,\n ),\n ),\n });\n return;\n }\n try {\n // the owner is the single sequencer: apply the routed ops through the store root, then\n // FLUSH so its opLog emits ONE authoritative owner-origin batch (fanned to every client,\n // the writer included — the echo that IS the write's confirmation). No baseline-advance\n // trick: this is a genuine owner write, indistinguishable from the worker writing itself.\n sub.writable.set(applyOps(sub.read(), msg.ops));\n sub.log.flush();\n conn.port.postMessage({\n type: 'store:write:ack',\n store: msg.store,\n writeId: msg.writeId,\n version: sub.version,\n });\n } catch (err) {\n conn.port.postMessage({\n type: 'store:write:error',\n store: msg.store,\n writeId: msg.writeId,\n error: serializeError(err),\n });\n }\n return;\n }\n }\n };\n\n const connect = (port: WorkerPortLike): (() => void) => {\n const conn: Connection = {\n port,\n clientId: null,\n stores: new Set(),\n taskRuns: new Map(),\n };\n connections.add(conn);\n // assigning onmessage starts a MessagePort; a Worker/self delivers the same way\n port.onmessage = (ev) => handle(conn, ev.data as WorkerEnvelope);\n return () => {\n connections.delete(conn);\n port.onmessage = null; // a disconnected client must not keep invoking tasks/writes\n for (const ac of conn.taskRuns.values()) ac.abort();\n conn.taskRuns.clear();\n };\n };\n\n const defaultPort: WorkerPortLike | undefined =\n options.port ??\n (isWorkerGlobal(globalThis)\n ? (globalThis as unknown as WorkerPortLike)\n : undefined);\n if (defaultPort) connect(defaultPort);\n\n return {\n hostId,\n connect,\n flush() {\n for (const { log } of subtrees.values()) log.flush();\n },\n dispose() {\n for (const { log } of subtrees.values()) log.destroy();\n for (const w of statusWatches) w.destroy();\n connections.clear();\n },\n };\n}\n","import { createStoreContext, type toStoreOptions } from '@mmstack/primitives';\n\n// Memoized at THIS module's scope. `@mmstack/worker/host` only ever loads inside a worker, so a\n// module-scope singleton here is a per-thread singleton — the worker equivalent of an app's root\n// injector (providedIn: 'root'). All stores in the worker share it; it is the graph's single\n// proxy-identity + GC-coordination point.\nlet context: toStoreOptions | undefined;\n\n/**\n * The one shared store context for this worker — pass it to every `store`/`toStore` you create in\n * the worker so they share proxy identity and cleanup, exactly as `providedIn: 'root'` shares one\n * cache across an app's stores.\n *\n * ```ts\n * // my.worker.ts\n * const todos = store<Todo[]>([], workerStoreContext());\n * const filter = store({ q: '' }, workerStoreContext()); // same context\n * ```\n */\nexport function workerStoreContext(): toStoreOptions {\n return (context ??= createStoreContext());\n}\n","// shared wire types, re-exported so worker code imports only from '@mmstack/worker/host'\nexport * from '@mmstack/worker/protocol';\n\nexport {\n createWorkerHost,\n type CreateWorkerHostOptions,\n type WorkerHost,\n type WorkerTaskHandler,\n} from './lib/create-worker-host';\nexport { microtaskOpLogDriver } from './lib/microtask-driver';\nexport { workerStoreContext } from './lib/worker-store-context';\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,78 @@
1
+ /** Wire-protocol version, negotiated in the hello/ready handshake. Bump on breaking envelope change. */
2
+ const PROTO_VERSION = 1;
3
+
4
+ function stringify(value) {
5
+ if (typeof value === 'string')
6
+ return value;
7
+ try {
8
+ return JSON.stringify(value) ?? String(value);
9
+ }
10
+ catch {
11
+ return String(value);
12
+ }
13
+ }
14
+ /** Renders any thrown value (Error or otherwise) into a {@link SerializedError}, chaining `cause`. */
15
+ function serializeError(err) {
16
+ if (err instanceof Error) {
17
+ return {
18
+ name: err.name,
19
+ message: err.message,
20
+ stack: err.stack,
21
+ cause: err.cause !== undefined ? serializeError(err.cause) : undefined,
22
+ };
23
+ }
24
+ return { name: 'Error', message: stringify(err) };
25
+ }
26
+ /** Rebuilds a real `Error` (name/message/stack/cause chain preserved) from a {@link SerializedError}. */
27
+ function deserializeError(e) {
28
+ const err = new Error(e.message);
29
+ err.name = e.name;
30
+ if (e.stack !== undefined)
31
+ err.stack = e.stack;
32
+ if (e.cause !== undefined)
33
+ err.cause = deserializeError(e.cause);
34
+ return err;
35
+ }
36
+
37
+ /** A short unique id for host/client identity on a shared transport. */
38
+ function generateId() {
39
+ if (globalThis.crypto?.randomUUID)
40
+ return globalThis.crypto.randomUUID();
41
+ return Math.random().toString(36).slice(2);
42
+ }
43
+
44
+ /** Closes a port by whichever teardown method it exposes. */
45
+ function closePort(port) {
46
+ port.terminate?.();
47
+ port.close?.();
48
+ }
49
+ // values whose reachable Transferables should be MOVED (detached at the sender) rather than cloned
50
+ const TRANSFERABLES = new WeakMap();
51
+ /**
52
+ * Marks `value` so that, when it is sent over a {@link WorkerPortLike}, the listed `Transferable`s
53
+ * (e.g. an `ArrayBuffer`) are moved rather than structure-cloned — zero-copy, but detached at the
54
+ * sender. Comlink-idiom. v1 honors this on task input/output only; store traffic always clones (a
55
+ * moved buffer inside a replicated tree would detach the owner's copy).
56
+ *
57
+ * ```ts
58
+ * const buf = new Float64Array(1e6);
59
+ * workerResource(() => transfer({ buf }, [buf.buffer]), { worker, task: 'process' });
60
+ * ```
61
+ */
62
+ function transfer(value, transferables) {
63
+ TRANSFERABLES.set(value, transferables);
64
+ return value;
65
+ }
66
+ /** @internal Reads back the Transferables registered on a value via {@link transfer}, if any. */
67
+ function takeTransferables(value) {
68
+ if (value !== null && typeof value === 'object')
69
+ return TRANSFERABLES.get(value);
70
+ return undefined;
71
+ }
72
+
73
+ /**
74
+ * Generated bundle index. Do not edit.
75
+ */
76
+
77
+ export { PROTO_VERSION, closePort, deserializeError, generateId, serializeError, takeTransferables, transfer };
78
+ //# sourceMappingURL=mmstack-worker-protocol.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mmstack-worker-protocol.mjs","sources":["../../../../packages/worker/protocol/src/lib/envelope.ts","../../../../packages/worker/protocol/src/lib/error.ts","../../../../packages/worker/protocol/src/lib/id.ts","../../../../packages/worker/protocol/src/lib/port.ts","../../../../packages/worker/protocol/src/mmstack-worker-protocol.ts"],"sourcesContent":["import type { OpBatch, StoreOp } from '@mmstack/primitives';\nimport type { SerializedError } from './error';\n\n/** Wire-protocol version, negotiated in the hello/ready handshake. Bump on breaking envelope change. */\nexport const PROTO_VERSION = 1;\nexport type ProtoVersion = typeof PROTO_VERSION;\n\n/** The status a remote (worker-owned) computation reports across the boundary — the rung-3 seam. */\nexport type RemoteStatus =\n | 'idle'\n | 'loading'\n | 'reloading'\n | 'resolved'\n | 'error';\n\n/**\n * Every message exchanged over a {@link WorkerPortLike}. One discriminated union shared by the\n * main-thread client and the worker host (and, later, `@mmstack/mesh`), so the two sides can never\n * drift. Grouped: lifecycle · tasks (rung 1) · stores (rung 2) · remote status (rung 3).\n */\nexport type WorkerEnvelope =\n // ── lifecycle ────────────────────────────────────────────────────────────\n | { type: 'hello'; proto: ProtoVersion; clientId: string }\n | {\n type: 'ready';\n proto: ProtoVersion;\n hostId: string;\n stores: readonly string[];\n published: readonly string[];\n tasks: readonly string[];\n }\n | { type: 'fatal'; error: SerializedError }\n // ── rung 1: tasks ────────────────────────────────────────────────────────\n | { type: 'task:run'; runId: number; task: string; input: unknown }\n | { type: 'task:abort'; runId: number }\n | { type: 'task:ok'; runId: number; value: unknown }\n | { type: 'task:error'; runId: number; error: SerializedError }\n // terminal ack for an aborted run — a non-cooperative handler settles eventually, and this\n // (rather than task:ok/task:error) is what its late settlement reports back as\n | { type: 'task:aborted'; runId: number }\n // ── rung 2: stores ───────────────────────────────────────────────────────\n | { type: 'store:subscribe'; store: string; clientId: string }\n | { type: 'store:snapshot'; store: string; version: number; value: unknown }\n | { type: 'store:ops'; store: string; batch: OpBatch }\n | {\n type: 'store:write';\n store: string;\n writeId: number;\n clientId: string;\n ops: readonly StoreOp[];\n }\n | { type: 'store:write:ack'; store: string; writeId: number; version: number }\n | { type: 'store:write:error'; store: string; writeId: number; error: SerializedError }\n | { type: 'store:unsubscribe'; store: string; clientId: string }\n // ── rung 3: remote status ────────────────────────────────────────────────\n | { type: 'store:status'; store: string; status: RemoteStatus; error?: SerializedError };\n\n/** Narrows a raw message to a specific envelope variant by its `type`. */\nexport type EnvelopeOf<K extends WorkerEnvelope['type']> = Extract<\n WorkerEnvelope,\n { type: K }\n>;\n","/**\n * A structured-clone-safe rendering of an `Error` for cross-thread propagation. A worker throw\n * can't cross `postMessage` as an `Error` (methods/prototype are lost), so tasks and store writes\n * serialize failures into this shape and the receiving side rebuilds a real `Error`.\n */\nexport type SerializedError = {\n name: string;\n message: string;\n stack?: string;\n cause?: SerializedError;\n};\n\nfunction stringify(value: unknown): string {\n if (typeof value === 'string') return value;\n try {\n return JSON.stringify(value) ?? String(value);\n } catch {\n return String(value);\n }\n}\n\n/** Renders any thrown value (Error or otherwise) into a {@link SerializedError}, chaining `cause`. */\nexport function serializeError(err: unknown): SerializedError {\n if (err instanceof Error) {\n return {\n name: err.name,\n message: err.message,\n stack: err.stack,\n cause: err.cause !== undefined ? serializeError(err.cause) : undefined,\n };\n }\n return { name: 'Error', message: stringify(err) };\n}\n\n/** Rebuilds a real `Error` (name/message/stack/cause chain preserved) from a {@link SerializedError}. */\nexport function deserializeError(e: SerializedError): Error {\n const err = new Error(e.message);\n err.name = e.name;\n if (e.stack !== undefined) err.stack = e.stack;\n if (e.cause !== undefined) err.cause = deserializeError(e.cause);\n return err;\n}\n","/** A short unique id for host/client identity on a shared transport. */\nexport function generateId(): string {\n if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();\n return Math.random().toString(36).slice(2);\n}\n","/**\n * The minimal structural transport the worker protocol rides on — the intersection of a `Worker`,\n * a `MessagePort`, and a `BroadcastChannel`. Modeled on `@mmstack/resource`'s `MutationSyncChannel`:\n * anything with `postMessage` + assignable `onmessage` qualifies, so a `MessageChannel` port-pair\n * works in tests with zero adapters and a future `@mmstack/mesh` can slot a WS/RTC channel behind\n * the same type.\n */\nexport type WorkerPortLike = {\n postMessage(message: unknown, transfer?: Transferable[]): void;\n // `ev` is intentionally `any`: a real `Worker`/`MessagePort` types this as `(ev: MessageEvent)`,\n // and under `strictFunctionTypes` only an `any`-typed event param keeps those assignable to this\n // shape cast-free (a `{ data }` param fails the contravariance check). Read `ev.data` and narrow\n // it to a `WorkerEnvelope` at the handler.\n onmessage: ((ev: any) => void) | null;\n /** `Worker` has `terminate()`, `MessagePort`/`BroadcastChannel` have `close()` — either, both optional. */\n terminate?(): void;\n close?(): void;\n};\n\n/** Closes a port by whichever teardown method it exposes. */\nexport function closePort(port: WorkerPortLike): void {\n port.terminate?.();\n port.close?.();\n}\n\n// values whose reachable Transferables should be MOVED (detached at the sender) rather than cloned\nconst TRANSFERABLES = new WeakMap<object, Transferable[]>();\n\n/**\n * Marks `value` so that, when it is sent over a {@link WorkerPortLike}, the listed `Transferable`s\n * (e.g. an `ArrayBuffer`) are moved rather than structure-cloned — zero-copy, but detached at the\n * sender. Comlink-idiom. v1 honors this on task input/output only; store traffic always clones (a\n * moved buffer inside a replicated tree would detach the owner's copy).\n *\n * ```ts\n * const buf = new Float64Array(1e6);\n * workerResource(() => transfer({ buf }, [buf.buffer]), { worker, task: 'process' });\n * ```\n */\nexport function transfer<T extends object>(value: T, transferables: Transferable[]): T {\n TRANSFERABLES.set(value, transferables);\n return value;\n}\n\n/** @internal Reads back the Transferables registered on a value via {@link transfer}, if any. */\nexport function takeTransferables(value: unknown): Transferable[] | undefined {\n if (value !== null && typeof value === 'object')\n return TRANSFERABLES.get(value as object);\n return undefined;\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"AAGA;AACO,MAAM,aAAa,GAAG;;ACQ7B,SAAS,SAAS,CAAC,KAAc,EAAA;IAC/B,IAAI,OAAO,KAAK,KAAK,QAAQ;AAAE,QAAA,OAAO,KAAK;AAC3C,IAAA,IAAI;QACF,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC;IAC/C;AAAE,IAAA,MAAM;AACN,QAAA,OAAO,MAAM,CAAC,KAAK,CAAC;IACtB;AACF;AAEA;AACM,SAAU,cAAc,CAAC,GAAY,EAAA;AACzC,IAAA,IAAI,GAAG,YAAY,KAAK,EAAE;QACxB,OAAO;YACL,IAAI,EAAE,GAAG,CAAC,IAAI;YACd,OAAO,EAAE,GAAG,CAAC,OAAO;YACpB,KAAK,EAAE,GAAG,CAAC,KAAK;AAChB,YAAA,KAAK,EAAE,GAAG,CAAC,KAAK,KAAK,SAAS,GAAG,cAAc,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,SAAS;SACvE;IACH;AACA,IAAA,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE;AACnD;AAEA;AACM,SAAU,gBAAgB,CAAC,CAAkB,EAAA;IACjD,MAAM,GAAG,GAAG,IAAI,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC;AAChC,IAAA,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI;AACjB,IAAA,IAAI,CAAC,CAAC,KAAK,KAAK,SAAS;AAAE,QAAA,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK;AAC9C,IAAA,IAAI,CAAC,CAAC,KAAK,KAAK,SAAS;QAAE,GAAG,CAAC,KAAK,GAAG,gBAAgB,CAAC,CAAC,CAAC,KAAK,CAAC;AAChE,IAAA,OAAO,GAAG;AACZ;;ACzCA;SACgB,UAAU,GAAA;AACxB,IAAA,IAAI,UAAU,CAAC,MAAM,EAAE,UAAU;AAAE,QAAA,OAAO,UAAU,CAAC,MAAM,CAAC,UAAU,EAAE;AACxE,IAAA,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;AAC5C;;ACeA;AACM,SAAU,SAAS,CAAC,IAAoB,EAAA;AAC5C,IAAA,IAAI,CAAC,SAAS,IAAI;AAClB,IAAA,IAAI,CAAC,KAAK,IAAI;AAChB;AAEA;AACA,MAAM,aAAa,GAAG,IAAI,OAAO,EAA0B;AAE3D;;;;;;;;;;AAUG;AACG,SAAU,QAAQ,CAAmB,KAAQ,EAAE,aAA6B,EAAA;AAChF,IAAA,aAAa,CAAC,GAAG,CAAC,KAAK,EAAE,aAAa,CAAC;AACvC,IAAA,OAAO,KAAK;AACd;AAEA;AACM,SAAU,iBAAiB,CAAC,KAAc,EAAA;AAC9C,IAAA,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;AAC7C,QAAA,OAAO,aAAa,CAAC,GAAG,CAAC,KAAe,CAAC;AAC3C,IAAA,OAAO,SAAS;AAClB;;ACjDA;;AAEG;;;;"}
@@ -0,0 +1,494 @@
1
+ import { generateId, deserializeError, closePort, takeTransferables } from '@mmstack/worker/protocol';
2
+ export * from '@mmstack/worker/protocol';
3
+ import { inject, Injector, runInInjectionContext, PLATFORM_ID, signal, DestroyRef, computed, effect, untracked } from '@angular/core';
4
+ import { injectTransitionScope, toStore, applyOps, diffOps } from '@mmstack/primitives';
5
+
6
+ /** Thrown into a task/write promise when it is aborted. `name` is `'AbortError'` (DOM convention). */
7
+ class WorkerAbortError extends Error {
8
+ constructor(message = 'The worker task was aborted') {
9
+ super(message);
10
+ this.name = 'AbortError';
11
+ }
12
+ }
13
+ /** Thrown into pending tasks/writes when the worker crashes. `name` is `'WorkerCrashedError'`. */
14
+ class WorkerCrashedError extends Error {
15
+ constructor(message = 'The worker crashed') {
16
+ super(message);
17
+ this.name = 'WorkerCrashedError';
18
+ }
19
+ }
20
+ /**
21
+ * Connects the main thread to a worker over a {@link WorkerPortLike} the caller provides via `spawn`
22
+ * — typically `() => new Worker(new URL('./my.worker', import.meta.url), { type: 'module' })` (the
23
+ * URL literal must live in APP code so the bundler emits the worker chunk). Performs the hello/ready
24
+ * handshake, exposes `connected`/`manifest`, routes named-task runs, and (via internal seams)
25
+ * carries the store-replication traffic for {@link workerStore}. On crash it respawns with backoff
26
+ * when `restart: 'auto'`.
27
+ */
28
+ function connectWorker(spawn, opt) {
29
+ const injector = opt?.injector ?? inject(Injector);
30
+ return runInInjectionContext(injector, () => build$2(spawn, opt));
31
+ }
32
+ function build$2(spawn, opt) {
33
+ const isServer = inject(PLATFORM_ID) === 'server';
34
+ const clientId = generateId();
35
+ const connected = signal(false, ...(ngDevMode ? [{ debugName: "connected" }] : /* istanbul ignore next */ []));
36
+ const manifest = signal(null, ...(ngDevMode ? [{ debugName: "manifest" }] : /* istanbul ignore next */ []));
37
+ const taskPending = new Map();
38
+ const storeHandlers = new Set();
39
+ const readyHooks = new Set();
40
+ const disconnectHooks = new Set();
41
+ const restart = opt?.restart ?? 'auto';
42
+ const restartDelay = opt?.restartDelay ?? ((n) => Math.min(30_000, 1000 * 2 ** n));
43
+ let attempt = 0;
44
+ let runIdSeq = 0;
45
+ let destroyed = false;
46
+ let crashed = false; // between a crash and the next successful handshake
47
+ let port = null;
48
+ const send = (msg, transfer) => {
49
+ if (port)
50
+ port.postMessage(msg, transfer); // no-op on the server / between crash and respawn
51
+ };
52
+ const onMessage = (msg) => {
53
+ switch (msg.type) {
54
+ case 'ready':
55
+ attempt = 0; // a successful handshake resets the backoff
56
+ crashed = false;
57
+ manifest.set({
58
+ hostId: msg.hostId,
59
+ stores: msg.stores,
60
+ published: msg.published,
61
+ tasks: msg.tasks,
62
+ });
63
+ connected.set(true);
64
+ for (const hook of readyHooks)
65
+ hook();
66
+ return;
67
+ case 'task:ok': {
68
+ const p = taskPending.get(msg.runId);
69
+ if (p) {
70
+ taskPending.delete(msg.runId);
71
+ p.resolve(msg.value);
72
+ }
73
+ return;
74
+ }
75
+ case 'task:error': {
76
+ const p = taskPending.get(msg.runId);
77
+ if (p) {
78
+ taskPending.delete(msg.runId);
79
+ p.reject(deserializeError(msg.error));
80
+ }
81
+ return;
82
+ }
83
+ case 'task:aborted':
84
+ taskPending.delete(msg.runId); // caller already rejected at abort
85
+ return;
86
+ default:
87
+ // store:* traffic → replicas
88
+ for (const h of storeHandlers)
89
+ h(msg);
90
+ }
91
+ };
92
+ const handleCrash = () => {
93
+ if (destroyed)
94
+ return;
95
+ crashed = true;
96
+ connected.set(false);
97
+ // in-flight tasks can't survive a crash; store replicas hold their value and re-hydrate on reconnect
98
+ for (const [, p] of taskPending)
99
+ p.reject(new WorkerCrashedError());
100
+ taskPending.clear();
101
+ for (const fn of disconnectHooks)
102
+ fn();
103
+ if (restart === 'manual')
104
+ return;
105
+ const delay = restartDelay(attempt++);
106
+ setTimeout(() => {
107
+ if (!destroyed)
108
+ openPort();
109
+ }, delay);
110
+ };
111
+ const openPort = () => {
112
+ // terminate the previous worker before respawning, so a transient/manual restart never orphans
113
+ // a live thread (a genuine crash already killed it; terminate() is then a harmless no-op)
114
+ if (port)
115
+ closePort(port);
116
+ port = spawn();
117
+ port.onmessage = (ev) => onMessage(ev.data);
118
+ // duck-typed crash detection via property setters (NOT addEventListener — that perturbs a node
119
+ // MessagePort's onmessage delivery). A real `Worker` has onerror; onmessageerror where present.
120
+ const evt = port;
121
+ if ('onerror' in evt)
122
+ evt.onerror = handleCrash;
123
+ if ('onmessageerror' in evt)
124
+ evt.onmessageerror = handleCrash;
125
+ send({ type: 'hello', proto: 1, clientId });
126
+ };
127
+ // no workers on the server — connected stays false, runTask rejects, replicas render their default
128
+ if (!isServer)
129
+ openPort();
130
+ inject(DestroyRef).onDestroy(() => teardown());
131
+ const teardown = () => {
132
+ destroyed = true;
133
+ connected.set(false);
134
+ for (const [, p] of taskPending)
135
+ p.reject(new WorkerAbortError('worker connection destroyed'));
136
+ taskPending.clear();
137
+ // same contract as a crash: anything pending on the connection (replica writes) settles NOW
138
+ // rather than dangling — a destroyed port can never deliver the echo that would resolve them
139
+ for (const fn of disconnectHooks)
140
+ fn();
141
+ if (port)
142
+ closePort(port);
143
+ };
144
+ return {
145
+ connected,
146
+ manifest,
147
+ clientId,
148
+ runTask(task, input, o) {
149
+ if (isServer)
150
+ return Promise.reject(new WorkerCrashedError('no worker on the server'));
151
+ if (destroyed)
152
+ return Promise.reject(new WorkerAbortError('worker connection destroyed'));
153
+ // between a crash and the respawned handshake the port is dead — a message posted into it
154
+ // vanishes and the promise would never settle. Reject honestly; the caller retries/reloads.
155
+ // (Before the FIRST handshake this is false: a starting worker buffers messages, so we send.)
156
+ if (crashed)
157
+ return Promise.reject(new WorkerCrashedError('worker is restarting'));
158
+ if (o?.signal?.aborted)
159
+ return Promise.reject(new WorkerAbortError());
160
+ const runId = ++runIdSeq;
161
+ return new Promise((resolve, reject) => {
162
+ taskPending.set(runId, { resolve, reject });
163
+ send({ type: 'task:run', runId, task, input }, o?.transfer ?? takeTransferables(input));
164
+ o?.signal?.addEventListener('abort', () => {
165
+ if (!taskPending.has(runId))
166
+ return;
167
+ taskPending.delete(runId);
168
+ send({ type: 'task:abort', runId });
169
+ reject(new WorkerAbortError());
170
+ }, { once: true });
171
+ });
172
+ },
173
+ destroy: teardown,
174
+ _send: send,
175
+ _subscribe(handler) {
176
+ storeHandlers.add(handler);
177
+ return () => storeHandlers.delete(handler);
178
+ },
179
+ _onReady(fn) {
180
+ readyHooks.add(fn);
181
+ return () => readyHooks.delete(fn);
182
+ },
183
+ _onDisconnect(fn) {
184
+ disconnectHooks.add(fn);
185
+ return () => disconnectHooks.delete(fn);
186
+ },
187
+ };
188
+ }
189
+
190
+ /** Returned from a paused `params` fn to HOLD (keep the current value/status, run nothing). */
191
+ const PAUSED = Symbol('@mmstack/worker:PAUSED');
192
+ /**
193
+ * Runs a named task on a connected worker, exposed with the standard resource surface so heavy
194
+ * compute makes the UI *pending*, not *frozen*. `params` reactively derives the input; latest-wins
195
+ * (a changed input supersedes and aborts the in-flight run); the result surfaces as
196
+ * `value`/`status`/`error`, and — with `register` — participates in transition scopes. No-ops on the
197
+ * server.
198
+ */
199
+ function workerResource(params, options) {
200
+ const injector = options.injector ?? inject(Injector);
201
+ return runInInjectionContext(injector, () => build$1(params, options));
202
+ }
203
+ function build$1(params, options) {
204
+ const isServer = inject(PLATFORM_ID) === 'server';
205
+ const keepPrevious = options.keepPrevious ?? true;
206
+ const userEqual = options.equal;
207
+ const { worker, task } = options;
208
+ const runTask = (input, signal) => worker.runTask(task, input, { signal });
209
+ const value = signal(options.defaultValue, { ...(ngDevMode ? { debugName: "value" } : /* istanbul ignore next */ {}), equal: userEqual
210
+ ? (a, b) => (a === undefined || b === undefined ? a === b : userEqual(a, b))
211
+ : undefined });
212
+ const status = signal('idle', ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
213
+ const error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
214
+ const isLoading = computed(() => {
215
+ const s = status();
216
+ return s === 'loading' || s === 'reloading';
217
+ }, ...(ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []));
218
+ const hasValue = () => value() !== undefined;
219
+ let epoch = 0;
220
+ let inFlight = null;
221
+ let lastInput;
222
+ let hasRun = false;
223
+ const start = (input) => {
224
+ const myEpoch = ++epoch;
225
+ inFlight?.abort();
226
+ const ac = new AbortController();
227
+ inFlight = ac;
228
+ lastInput = input;
229
+ hasRun = true;
230
+ status.set(keepPrevious && value() !== undefined ? 'reloading' : 'loading');
231
+ error.set(undefined);
232
+ runTask(input, ac.signal).then((result) => {
233
+ if (inFlight === ac)
234
+ inFlight = null;
235
+ if (myEpoch !== epoch)
236
+ return; // superseded — discard
237
+ value.set(result);
238
+ status.set('resolved');
239
+ }, (err) => {
240
+ if (inFlight === ac)
241
+ inFlight = null;
242
+ if (myEpoch !== epoch)
243
+ return;
244
+ if (err instanceof WorkerAbortError || err?.name === 'AbortError')
245
+ return;
246
+ error.set(err);
247
+ status.set('error');
248
+ });
249
+ };
250
+ const input = computed(() => params({ paused: PAUSED }), ...(ngDevMode ? [{ debugName: "input" }] : /* istanbul ignore next */ []));
251
+ const ref = effect(() => {
252
+ const i = input();
253
+ if (isServer || i === PAUSED)
254
+ return; // server: never run · paused: hold
255
+ if (i === undefined)
256
+ return; // disabled — keep current value/status
257
+ // dedup: a resume to the same input (e.g. after a pause) does not re-run
258
+ if (Object.is(i, lastInput) && hasRun && untracked(status) !== 'error')
259
+ return;
260
+ untracked(() => start(i));
261
+ }, ...(ngDevMode ? [{ debugName: "ref" }] : /* istanbul ignore next */ []));
262
+ inject(DestroyRef).onDestroy(() => {
263
+ epoch++;
264
+ inFlight?.abort();
265
+ });
266
+ const self = {
267
+ value,
268
+ status,
269
+ error,
270
+ isLoading,
271
+ hasValue,
272
+ reload: () => {
273
+ const i = untracked(input);
274
+ if (isServer || i === PAUSED || i === undefined)
275
+ return;
276
+ start(i);
277
+ },
278
+ abort: () => {
279
+ if (!inFlight)
280
+ return; // nothing in flight (a scope's abortPending must not disturb a settled value)
281
+ epoch++;
282
+ inFlight.abort();
283
+ inFlight = null;
284
+ status.set('local'); // value kept
285
+ },
286
+ destroy: () => {
287
+ epoch++;
288
+ inFlight?.abort();
289
+ ref.destroy();
290
+ },
291
+ };
292
+ if (options.register) {
293
+ const scope = injectTransitionScope();
294
+ scope.add(self, { suspends: options.register === 'suspend' });
295
+ inject(DestroyRef).onDestroy(() => scope.remove(self));
296
+ }
297
+ return self;
298
+ }
299
+
300
+ function workerStore(worker, key, opt) {
301
+ const injector = opt?.injector ?? inject(Injector);
302
+ return runInInjectionContext(injector, () => build(worker, key, opt));
303
+ }
304
+ function build(worker, key, opt) {
305
+ const injector = inject(Injector); // captured for use in write() (called outside injection context)
306
+ const root = signal(opt?.defaultValue, ...(ngDevMode ? [{ debugName: "root" }] : /* istanbul ignore next */ []));
307
+ // shape-adaptive: the store reflects `root` even as it goes undefined → object on first snapshot
308
+ const replica = toStore(root, {
309
+ injector,
310
+ });
311
+ const status = signal('loading', ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
312
+ const error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
313
+ const isLoading = computed(() => {
314
+ const s = status();
315
+ return s === 'loading' || s === 'reloading';
316
+ }, ...(ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []));
317
+ let hasSnapshot = false;
318
+ let resyncing = false; // awaiting a fresh snapshot after a detected gap
319
+ let lastVersion = 0;
320
+ let writeSeq = 0;
321
+ const writePending = new Map();
322
+ const hasValue = () => hasSnapshot;
323
+ // resolve any write whose acked authoritative version has now been applied to this replica
324
+ const settleWrites = () => {
325
+ for (const [id, p] of writePending) {
326
+ if (p.version !== null && lastVersion >= p.version) {
327
+ writePending.delete(id);
328
+ p.resolve();
329
+ }
330
+ }
331
+ };
332
+ const onStoreMessage = (msg) => {
333
+ if (!('store' in msg) || msg.store !== key)
334
+ return;
335
+ switch (msg.type) {
336
+ case 'store:snapshot': {
337
+ root.set(msg.value);
338
+ lastVersion = msg.version;
339
+ hasSnapshot = true;
340
+ resyncing = false; // a fresh snapshot ends any resync
341
+ status.set('resolved');
342
+ error.set(undefined);
343
+ settleWrites();
344
+ return;
345
+ }
346
+ case 'store:ops': {
347
+ if (!hasSnapshot || resyncing)
348
+ return; // pre-hydration, or dropping until a fresh snapshot
349
+ if (msg.batch.version <= lastVersion)
350
+ return; // stale/duplicate (at-least-once transport)
351
+ if (msg.batch.version > lastVersion + 1) {
352
+ // GAP: a batch was lost — re-hydrate from a fresh snapshot rather than apply out of order
353
+ resync();
354
+ return;
355
+ }
356
+ root.set(applyOps(untracked(root), msg.batch.ops));
357
+ lastVersion = msg.batch.version;
358
+ settleWrites();
359
+ return;
360
+ }
361
+ case 'store:write:ack': {
362
+ const p = writePending.get(msg.writeId);
363
+ if (!p)
364
+ return;
365
+ if (lastVersion >= msg.version) {
366
+ writePending.delete(msg.writeId);
367
+ p.resolve(); // the authoritative batch already landed (FIFO: ops precede the ack)
368
+ }
369
+ else {
370
+ p.version = msg.version; // resolve once a batch reaches this version
371
+ }
372
+ return;
373
+ }
374
+ case 'store:write:error': {
375
+ const p = writePending.get(msg.writeId);
376
+ if (p) {
377
+ writePending.delete(msg.writeId);
378
+ p.reject(deserializeError(msg.error));
379
+ }
380
+ return;
381
+ }
382
+ case 'store:status': {
383
+ // rung 3: a published derivation's remote status → this replica's status, so an in-flight
384
+ // worker computation shows as pending on the main thread (holding the last value)
385
+ switch (msg.status) {
386
+ case 'error':
387
+ error.set(msg.error ? deserializeError(msg.error) : new Error('remote computation error'));
388
+ status.set('error');
389
+ return;
390
+ case 'loading':
391
+ case 'reloading':
392
+ error.set(undefined);
393
+ status.set(hasSnapshot ? 'reloading' : 'loading'); // hold the value while recomputing
394
+ return;
395
+ case 'resolved':
396
+ error.set(undefined);
397
+ if (hasSnapshot)
398
+ status.set('resolved');
399
+ return;
400
+ default:
401
+ return;
402
+ }
403
+ }
404
+ }
405
+ };
406
+ const subscribe = () => {
407
+ if (hasSnapshot)
408
+ status.set('reloading'); // holding stale while the fresh snapshot lands
409
+ worker._send({ type: 'store:subscribe', store: key, clientId: worker.clientId });
410
+ };
411
+ // a lost batch left a version gap — hold the stale value and re-subscribe for a fresh snapshot
412
+ const resync = () => {
413
+ resyncing = true;
414
+ status.set('reloading');
415
+ worker._send({ type: 'store:subscribe', store: key, clientId: worker.clientId });
416
+ };
417
+ const unsub = worker._subscribe(onStoreMessage);
418
+ // subscribe on each `ready` — the first connection and every auto-restart re-hydration; if the
419
+ // connection is ALREADY up (created post-handshake), `_onReady` won't fire, so subscribe now
420
+ const offReady = worker._onReady(subscribe);
421
+ if (untracked(worker.connected))
422
+ subscribe();
423
+ // a crash can't be delivered — pending writes reject (the caller holds the value + recipe to retry)
424
+ const offDisconnect = worker._onDisconnect(() => {
425
+ for (const [, p] of writePending)
426
+ p.reject(new WorkerCrashedError());
427
+ writePending.clear();
428
+ });
429
+ const self = {
430
+ store: replica,
431
+ value: root.asReadonly(),
432
+ status,
433
+ error,
434
+ isLoading,
435
+ connected: worker.connected,
436
+ hasValue,
437
+ write: (recipe) => {
438
+ const base = untracked(root);
439
+ if (base === undefined)
440
+ return Promise.reject(new Error('[@mmstack/worker] cannot write before the replica has hydrated'));
441
+ // hydrated but disconnected = the worker crashed (or is restarting) — a write posted now
442
+ // vanishes into a dead port and its echo can never arrive. Reject; the caller retries.
443
+ if (!untracked(worker.connected))
444
+ return Promise.reject(new WorkerCrashedError('worker is not connected'));
445
+ // fork-diff: a scratch store seeded with the CURRENT value (same ref → copy-on-write keeps
446
+ // untouched subtrees shared), so diffOps against the base is O(changed paths)
447
+ const scratch = signal(base, ...(ngDevMode ? [{ debugName: "scratch" }] : /* istanbul ignore next */ []));
448
+ const scratchStore = toStore(scratch, {
449
+ injector,
450
+ });
451
+ recipe(scratchStore);
452
+ const ops = diffOps(base, untracked(scratch));
453
+ if (!ops.length)
454
+ return Promise.resolve();
455
+ const writeId = ++writeSeq;
456
+ return new Promise((resolve, reject) => {
457
+ writePending.set(writeId, { resolve, reject, version: null });
458
+ worker._send({
459
+ type: 'store:write',
460
+ store: key,
461
+ writeId,
462
+ clientId: worker.clientId,
463
+ ops,
464
+ });
465
+ });
466
+ },
467
+ destroy: () => {
468
+ unsub();
469
+ offReady();
470
+ offDisconnect();
471
+ // acks can no longer be received — settle any in-flight write instead of dangling it
472
+ for (const [, p] of writePending)
473
+ p.reject(new WorkerAbortError('worker store destroyed'));
474
+ writePending.clear();
475
+ worker._send({ type: 'store:unsubscribe', store: key, clientId: worker.clientId });
476
+ },
477
+ };
478
+ inject(DestroyRef).onDestroy(() => self.destroy());
479
+ if (opt?.register) {
480
+ const scope = injectTransitionScope();
481
+ scope.add(self, { suspends: opt.register === 'suspend' });
482
+ inject(DestroyRef).onDestroy(() => scope.remove(self));
483
+ }
484
+ return self;
485
+ }
486
+
487
+ // shared wire types, re-exported so main-thread consumers import only from '@mmstack/worker'
488
+
489
+ /**
490
+ * Generated bundle index. Do not edit.
491
+ */
492
+
493
+ export { PAUSED, WorkerAbortError, WorkerCrashedError, connectWorker, workerResource, workerStore };
494
+ //# sourceMappingURL=mmstack-worker.mjs.map