@atolljs/core 0.1.13 → 0.1.15

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.
Files changed (40) hide show
  1. package/dist/contract/listSchema.js +1 -2
  2. package/dist/contract/memoryDevtools.js +2 -3
  3. package/dist/contract/msgpackrCodec.js +1 -2
  4. package/dist/contract/reef.js +1 -2
  5. package/dist/contract/sharedMemory.js +61 -62
  6. package/dist/contract/types.d.ts +24 -0
  7. package/dist/contract/zod.js +1 -2
  8. package/dist/devtools.js +32 -39
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +22 -22
  11. package/dist/log.js +1 -2
  12. package/dist/observable.js +18 -19
  13. package/dist/pool/dedicatedWorker.d.ts +3 -0
  14. package/dist/pool/dedicatedWorker.js +72 -68
  15. package/dist/pool/devtoolsCommands.js +1 -2
  16. package/dist/pool/errors.js +1 -2
  17. package/dist/pool/memory.js +1 -2
  18. package/dist/pool/subWorkerClient.js +1 -2
  19. package/dist/pool/workerChannel.js +21 -22
  20. package/dist/pool/workerClient.d.ts +15 -1
  21. package/dist/pool/workerClient.js +14 -10
  22. package/dist/pool/workerPool.d.ts +3 -0
  23. package/dist/pool/workerPool.js +119 -121
  24. package/dist/reactive.js +1 -2
  25. package/dist/reactiveGraph.js +1 -2
  26. package/dist/service.js +1 -2
  27. package/dist/shared/sharedWorkerClient.js +2 -9
  28. package/dist/shared/sharedWorkerHost.js +22 -27
  29. package/dist/task.js +1 -2
  30. package/dist/testing/inProcessWorker.js +3 -8
  31. package/dist/userTiming.js +1 -2
  32. package/dist/worker/defineWorker.js +8 -9
  33. package/dist/worker/registry.js +1 -2
  34. package/dist/worker/workerBootstrap.js +40 -52
  35. package/package.json +11 -7
  36. package/src/contract/types.ts +26 -0
  37. package/src/index.ts +2 -0
  38. package/src/pool/dedicatedWorker.ts +19 -1
  39. package/src/pool/workerClient.ts +30 -1
  40. package/src/pool/workerPool.ts +11 -1
@@ -1,12 +1,12 @@
1
- import { devtoolsEnabled as e, enableWorkerDevtoolsForwarding as t } from "../devtools.js";
2
- import { fmtBytes as n, scoped as r } from "../log.js";
3
- import { bindSharedMemories as i, getDefinedSharedMemoryCount as a } from "../contract/sharedMemory.js";
4
- import { measureAtoll as o } from "../userTiming.js";
5
- import { TaskRegistry as s } from "./registry.js";
6
- //#region src/worker/workerBootstrap.ts
7
- var c = r("worker"), l = (e, t, n, r) => {
1
+ import { TaskRegistry as e } from "./registry.js";
2
+ import { bindSharedMemories as t, getDefinedSharedMemoryCount as n } from "../contract/sharedMemory.js";
3
+ import { devtoolsEnabled as r, enableWorkerDevtoolsForwarding as i } from "../devtools.js";
4
+ import { fmtBytes as a, scoped as o } from "../log.js";
5
+ import { measureAtoll as s } from "../userTiming.js";
6
+ // src/worker/workerBootstrap.ts
7
+ var c = o("worker"), l = (e, t, n, r) => {
8
8
  let i = performance.now();
9
- o(`atoll run ${e}`, n, i, {
9
+ s(`atoll run ${e}`, n, i, {
10
10
  track: "tasks",
11
11
  color: r ? "secondary" : "error",
12
12
  properties: [["message", t], ["outcome", r ? "ok" : "error"]],
@@ -14,55 +14,43 @@ var c = r("worker"), l = (e, t, n, r) => {
14
14
  });
15
15
  }, u = !1, d = !1;
16
16
  function f() {
17
- if (!d) {
18
- if (typeof self > "u") {
19
- c.warn("workerBootstrap: no worker global found — skipping message wiring");
20
- return;
21
- }
22
- d = !0, self.onmessage = async (r) => {
23
- let o = r.data;
24
- if (o.type === "INIT") {
25
- u = !0, o.devtools && t(), a() > 0 && c.warn("pool sent INIT without memory, but shared-memory contracts are defined — field access will throw until bound"), c.info("initialized (message-only pool — no shared memory)");
17
+ d || (typeof self > "u" ? c.warn("workerBootstrap: no worker global found — skipping message wiring") : (d = !0, self.onmessage = async (o) => {
18
+ let s = o.data;
19
+ if (s.type === "INIT") u = !0, s.devtools && i(), n() > 0 && c.warn("pool sent INIT without memory, but shared-memory contracts are defined — field access will throw until bound"), c.info("initialized (message-only pool — no shared memory)");
20
+ else if (s.type === "INIT_MEMORY") {
21
+ let e = s.memory?.buffer ?? s.buffer;
22
+ if (!e) {
23
+ c.warn("INIT_MEMORY arrived without a buffer — contracts stay unbound");
26
24
  return;
27
25
  }
28
- if (o.type === "INIT_MEMORY") {
29
- let e = o.memory?.buffer ?? o.buffer;
30
- if (!e) {
31
- c.warn("INIT_MEMORY arrived without a buffer — contracts stay unbound");
32
- return;
33
- }
34
- i(e), u = !0, o.devtools && t(), c.info(`shared memory initialized (${n(e.byteLength)})`);
26
+ t(e), u = !0, s.devtools && i(), c.info(`shared memory initialized (${a(e.byteLength)})`);
27
+ } else if (s.type === "EXECUTE_TASK") {
28
+ let { messageId: t, taskId: n, args: i } = s;
29
+ if (!u) {
30
+ c.warn(`rejected ${n} — shared memory not initialized`), self.postMessage({
31
+ messageId: t,
32
+ success: !1,
33
+ error: "Shared memory not initialized in worker."
34
+ });
35
35
  return;
36
36
  }
37
- if (o.type === "EXECUTE_TASK") {
38
- let { messageId: t, taskId: n, args: r } = o;
39
- if (!u) {
40
- c.warn(`rejected ${n} — shared memory not initialized`), self.postMessage({
41
- messageId: t,
42
- success: !1,
43
- error: "Shared memory not initialized in worker."
44
- });
45
- return;
46
- }
47
- let i = performance.now();
48
- try {
49
- let a = await s.execute(n, ...r || []);
50
- e() && l(n, t, i, !0), c.debug(`task ${n} → ${(performance.now() - i).toFixed(1)}ms`), self.postMessage({
51
- messageId: t,
52
- success: !0,
53
- result: a
54
- });
55
- } catch (r) {
56
- e() && l(n, t, i, !1), c.warn(`task ${n} failed: ${r.message || String(r)}`), self.postMessage({
57
- messageId: t,
58
- success: !1,
59
- error: r.message || String(r)
60
- });
61
- }
37
+ let a = performance.now();
38
+ try {
39
+ let o = await e.execute(n, ...i || []);
40
+ r() && l(n, t, a, !0), c.debug(`task ${n} → ${(performance.now() - a).toFixed(1)}ms`), self.postMessage({
41
+ messageId: t,
42
+ success: !0,
43
+ result: o
44
+ });
45
+ } catch (e) {
46
+ r() && l(n, t, a, !1), c.warn(`task ${n} failed: ${e.message || String(e)}`), self.postMessage({
47
+ messageId: t,
48
+ success: !1,
49
+ error: e.message || String(e)
50
+ });
62
51
  }
63
- };
64
- }
52
+ }
53
+ }));
65
54
  }
66
55
  queueMicrotask(f);
67
- //#endregion
68
56
  export { f as installWorkerListener };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolljs/core",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Typed shared-memory worker pools — defineWorker/connectWorker, SharedArrayBuffer contracts, cross-thread reactivity",
5
5
  "homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
6
6
  "type": "module",
@@ -55,10 +55,10 @@
55
55
  "@types/react": "^19.3.0",
56
56
  "@types/react-dom": "^19.3.0",
57
57
  "@vitejs/plugin-vue": "^6.0.9",
58
- "@vitest/coverage-v8": "^4.1.11",
58
+ "@vitest/coverage-v8": "^4.1.12",
59
59
  "esbuild": "0.28.2",
60
60
  "fast-json-stringify": "^7.0.1",
61
- "happy-dom": "^20.14.5",
61
+ "happy-dom": "^21.0.4",
62
62
  "htmlparser2": "^12.0.0",
63
63
  "nx": "23.3.0",
64
64
  "react": "^19.3.0",
@@ -68,10 +68,10 @@
68
68
  "rxjs": "^7.8.2",
69
69
  "svelte": "^5.57.1",
70
70
  "typescript": "^7.0.2",
71
- "vite": "^8.3.1",
72
- "vitest": "^4.1.11",
71
+ "vite": "^8.3.5",
72
+ "vitest": "^4.1.12",
73
73
  "vue": "^3.5.43",
74
- "zod": "^4.6.5"
74
+ "zod": "^4.6.7"
75
75
  },
76
76
  "dependencies": {
77
77
  "msgpackr": "^2.1.0",
@@ -79,7 +79,11 @@
79
79
  },
80
80
  "overrides": {
81
81
  "nx": {
82
- "undici": "7.29.1"
82
+ "undici": "7.30.1",
83
+ "minimatch": "11.0.4"
84
+ },
85
+ "@nx/devkit": {
86
+ "minimatch": "11.0.4"
83
87
  }
84
88
  },
85
89
  "repository": {
@@ -41,6 +41,25 @@ export interface MemoryPersistence {
41
41
  stop(): void | Promise<void>;
42
42
  }
43
43
 
44
+ /**
45
+ * A worker lifecycle transition, emitted by pools and dedicated workers
46
+ * through `onLifecycle`/`onWorkerEvent` — the seam error boundaries build
47
+ * on. `'error'` fires when a worker's error event lands (before respawn);
48
+ * `'respawn'` when the replacement is attached; `'terminate'` when the
49
+ * runner tears down deliberately.
50
+ */
51
+ export interface WorkerLifecycleEvent {
52
+ type: 'error' | 'respawn' | 'terminate';
53
+ /** The emitting runner's devtools correlation id ('worker-N'/'pool-N'). */
54
+ poolId: string;
55
+ /** Worker slot in a pool; always 0 for a dedicated worker. */
56
+ slot?: number;
57
+ /** The crash/error message on 'error' events. */
58
+ message?: string;
59
+ }
60
+
61
+ export type WorkerLifecycleListener = (event: WorkerLifecycleEvent) => void;
62
+
44
63
  export interface WorkerPoolConfig<S extends SharedSpec = SharedSpec, T extends TaskMap = TaskMap> {
45
64
  /** Worker entry as a URL — e.g. `new URL('./worker.ts', import.meta.url)`. */
46
65
  workerUrl?: URL;
@@ -85,6 +104,13 @@ export interface WorkerPoolConfig<S extends SharedSpec = SharedSpec, T extends T
85
104
  maxQueue?: number;
86
105
  /** Default true — a worker 'error' rejects its in-flight tasks and spawns a replacement. */
87
106
  respawn?: boolean;
107
+ /**
108
+ * Lifecycle observer — called on worker 'error' (before respawn),
109
+ * 'respawn', and 'terminate'. Island boundaries subscribe through
110
+ * `client.onWorkerEvent` to detect crashes; config-level listeners get
111
+ * the same events.
112
+ */
113
+ onLifecycle?: WorkerLifecycleListener;
88
114
  /**
89
115
  * Default per-task timeout in ms (per-call `options.timeout` overrides).
90
116
  * The timer starts at enqueue, so it bounds queue-wait + execution together.
package/src/index.ts CHANGED
@@ -48,6 +48,8 @@ export type {
48
48
  MemoryPersistence,
49
49
  SharedWorkerConfig,
50
50
  ViewType,
51
+ WorkerLifecycleEvent,
52
+ WorkerLifecycleListener,
51
53
  WorkerPoolConfig,
52
54
  } from './contract/types';
53
55
 
@@ -1,7 +1,15 @@
1
1
  import type { MemoryManager } from './memory';
2
2
  import { TaskAbortedError, TaskTimeoutError, WorkerCrashedError } from './errors';
3
3
  import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
4
- import type { MemoryPersistence, TaskContract, TaskMap, TaskResult, WorkerPoolConfig } from '../contract/types';
4
+ import type {
5
+ MemoryPersistence,
6
+ TaskContract,
7
+ TaskMap,
8
+ TaskResult,
9
+ WorkerLifecycleEvent,
10
+ WorkerLifecycleListener,
11
+ WorkerPoolConfig,
12
+ } from '../contract/types';
5
13
  import { devtoolsEnabled, emitDevtools, estimateCloneBytes, nextDevtoolsId, type EmittedDevtoolsEvent } from '../devtools';
6
14
  import { fmtBytes, scoped } from '../log';
7
15
  import { measureTaskCall } from '../userTiming';
@@ -66,6 +74,7 @@ export class DedicatedWorker<S extends SharedSpec = SharedSpec> {
66
74
  private readonly mem: ChannelMemory<S>;
67
75
  private readonly spawn: () => Worker;
68
76
  private readonly respawn: boolean;
77
+ private readonly onLifecycle?: WorkerLifecycleListener;
69
78
  private readonly taskTimeout?: number;
70
79
  private readonly label?: string;
71
80
  private worker: Worker | null = null;
@@ -87,6 +96,7 @@ export class DedicatedWorker<S extends SharedSpec = SharedSpec> {
87
96
  const url = config.workerUrl;
88
97
  this.spawn = config.createWorker ?? (() => new Worker(url!, { type: 'module' }));
89
98
  this.respawn = config.respawn ?? true;
99
+ this.onLifecycle = config.onLifecycle;
90
100
  this.taskTimeout = config.taskTimeout;
91
101
  this.label = config.name;
92
102
  this.poolId = nextDevtoolsId('worker', config.name);
@@ -178,12 +188,18 @@ export class DedicatedWorker<S extends SharedSpec = SharedSpec> {
178
188
  this.checkDrained();
179
189
  }
180
190
 
191
+ /** Fan out a lifecycle transition — error boundaries subscribe through this. */
192
+ private lifecycle(type: WorkerLifecycleEvent['type'], message?: string): void {
193
+ this.onLifecycle?.({ type, poolId: this.poolId, slot: 0, message });
194
+ }
195
+
181
196
  private handleError(event: unknown): void {
182
197
  if (this.closed) return;
183
198
  const e = event as { message?: string; error?: Error } | undefined;
184
199
  const message = e?.message ?? (e?.error ? String(e.error) : 'worker error');
185
200
  workerLog.warn(`worker error: ${message}`);
186
201
  emitDevtools({ type: 'worker:error', poolId: this.poolId, slot: 0, message });
202
+ this.lifecycle('error', message);
187
203
  this.rejectPending(message);
188
204
  this.worker?.terminate();
189
205
  this.worker = null;
@@ -191,6 +207,7 @@ export class DedicatedWorker<S extends SharedSpec = SharedSpec> {
191
207
  workerLog.info('respawning crashed worker');
192
208
  this.attach(this.spawn());
193
209
  emitDevtools({ type: 'worker:respawn', poolId: this.poolId, slot: 0 });
210
+ this.lifecycle('respawn');
194
211
  }
195
212
  this.checkDrained();
196
213
  }
@@ -320,6 +337,7 @@ export class DedicatedWorker<S extends SharedSpec = SharedSpec> {
320
337
  public terminate(): void {
321
338
  if (this.closed) return;
322
339
  this.closed = true;
340
+ this.lifecycle('terminate');
323
341
  this.devtoolsOff?.();
324
342
  void this.persistence?.stop()?.catch?.((e: unknown) => workerLog.warn('memory persistence stop failed', e));
325
343
  workerLog.info('terminating dedicated worker');
@@ -1,6 +1,10 @@
1
1
  import type { ServiceMethod, TaskRunner } from '../service';
2
2
  import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
3
- import type { MemoryConfig, Prettify } from '../contract/types';
3
+ import type {
4
+ MemoryConfig,
5
+ Prettify,
6
+ WorkerLifecycleListener,
7
+ } from '../contract/types';
4
8
  import { WorkerPool, type RunOptions } from './workerPool';
5
9
  import { DedicatedWorker } from './dedicatedWorker';
6
10
  import type {
@@ -123,6 +127,13 @@ export interface ConnectWorkerConfig<S extends SharedSpec> {
123
127
  maxQueue?: number;
124
128
  /** Default true — a crashed worker is replaced and its in-flight calls reject. */
125
129
  respawn?: boolean;
130
+ /**
131
+ * Lifecycle observer — fires on worker 'error' (before respawn),
132
+ * 'respawn', and 'terminate'. For per-callsite subscriptions (island
133
+ * boundaries, telemetry) prefer `client.onWorkerEvent(fn)`, which can be
134
+ * attached after connect and supports multiple listeners.
135
+ */
136
+ onLifecycle?: WorkerLifecycleListener;
126
137
  /** Default per-call timeout ms (`client.with({ timeout })` overrides). */
127
138
  taskTimeout?: number;
128
139
  /** Default true: pool spawns on first method call (or start()). False spawns immediately. */
@@ -153,6 +164,13 @@ export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = Wor
153
164
  */
154
165
  readonly pool: WorkerRunner<S> | null;
155
166
  readonly sharedMemory: ConnectWorkerConfig<S>['sharedMemory'] | undefined;
167
+ /**
168
+ * Subscribe to worker lifecycle transitions ('error' | 'respawn' |
169
+ * 'terminate') — the crash-detection seam error boundaries build on.
170
+ * Listeners attach lazily: subscribing before the pool spawns still sees
171
+ * every event. Returns the unsubscribe.
172
+ */
173
+ onWorkerEvent(fn: WorkerLifecycleListener): () => void;
156
174
  };
157
175
 
158
176
  /**
@@ -181,6 +199,9 @@ export function connectWorker<
181
199
  S extends SharedSpec = W extends WorkerDefinition<infer X> ? X : SharedSpec,
182
200
  >(config: ConnectWorkerConfig<S>): WorkerClient<W, S> {
183
201
  let pool: WorkerRunner<S> | null = null;
202
+ // Lifecycle subscribers register before/after spawn identically — the set
203
+ // outlives any single runner, and the runner's config fan-out reads it.
204
+ const lifecycleSubs = new Set<WorkerLifecycleListener>();
184
205
  const spawn = (): WorkerRunner<S> => {
185
206
  if (pool) return pool;
186
207
  const count = resolveWorkerCount(config.workers ?? config.poolSize);
@@ -195,6 +216,10 @@ export function connectWorker<
195
216
  memory: config.memory,
196
217
  respawn: config.respawn,
197
218
  taskTimeout: config.taskTimeout,
219
+ onLifecycle: (event: Parameters<WorkerLifecycleListener>[0]) => {
220
+ config.onLifecycle?.(event);
221
+ for (const fn of [...lifecycleSubs]) fn(event);
222
+ },
198
223
  };
199
224
  pool =
200
225
  count === 1
@@ -224,6 +249,10 @@ export function connectWorker<
224
249
  get sharedMemory() {
225
250
  return config.sharedMemory;
226
251
  },
252
+ onWorkerEvent: (fn: WorkerLifecycleListener): (() => void) => {
253
+ lifecycleSubs.add(fn);
254
+ return () => lifecycleSubs.delete(fn);
255
+ },
227
256
  };
228
257
 
229
258
  return makeClientProxy([], spawn, reserved) as WorkerClient<W, S>;
@@ -1,7 +1,7 @@
1
1
  import type { MemoryManager } from './memory';
2
2
  import { PoolQueueFullError, TaskAbortedError, TaskTimeoutError, WorkerCrashedError } from './errors';
3
3
  import { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
4
- import { MemoryPersistence, PoolTasks, TaskContract, TaskMap, TaskResult, WorkerPoolConfig } from '../contract/types';
4
+ import { MemoryPersistence, PoolTasks, TaskContract, TaskMap, TaskResult, WorkerLifecycleEvent, WorkerLifecycleListener, WorkerPoolConfig } from '../contract/types';
5
5
  import { devtoolsEnabled, emitDevtools, estimateCloneBytes, nextDevtoolsId, type EmittedDevtoolsEvent } from '../devtools';
6
6
  import { measureTaskCall } from '../userTiming';
7
7
  import { forwardWorkerDevtools, sendInit, setupChannelMemory, type ChannelMemory } from './workerChannel';
@@ -98,6 +98,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
98
98
  private readonly concurrency: number;
99
99
  private readonly maxQueue: number;
100
100
  private readonly respawn: boolean;
101
+ private readonly onLifecycle?: WorkerLifecycleListener;
101
102
  private readonly taskTimeout?: number;
102
103
  private closed = false;
103
104
  /** Dashboard fault injection (`pool.chaos`); null unless devtools set it. */
@@ -133,6 +134,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
133
134
  this.concurrency = config.concurrency ?? 1;
134
135
  this.maxQueue = config.maxQueue ?? Infinity;
135
136
  this.respawn = config.respawn ?? true;
137
+ this.onLifecycle = config.onLifecycle;
136
138
  this.taskTimeout = config.taskTimeout;
137
139
  this.label = config.name;
138
140
  this.poolId = nextDevtoolsId('pool', config.name);
@@ -264,6 +266,11 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
264
266
  this.checkDrained();
265
267
  }
266
268
 
269
+ /** Fan out a lifecycle transition — error boundaries subscribe through this. */
270
+ private lifecycle(type: WorkerLifecycleEvent['type'], slot?: number, message?: string): void {
271
+ this.onLifecycle?.({ type, poolId: this.poolId, slot, message });
272
+ }
273
+
267
274
  private handleWorkerError(slot: WorkerSlot, event: unknown) {
268
275
  if (this.closed) return; // pool terminated — a late error event can't respawn
269
276
  const e = event as { message?: string; error?: Error } | undefined;
@@ -271,6 +278,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
271
278
  poolLog.warn(`worker error: ${message}`);
272
279
  const idx = this.slots.indexOf(slot);
273
280
  emitDevtools({ type: 'worker:error', poolId: this.poolId, slot: idx, message });
281
+ this.lifecycle('error', idx >= 0 ? idx : undefined, message);
274
282
 
275
283
  // Reject the dead worker's in-flight tasks. Orphaned (already
276
284
  // aborted/timed-out) entries are dropped without re-counting.
@@ -293,6 +301,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
293
301
  poolLog.info('respawning crashed worker');
294
302
  this.addWorker(this.spawn());
295
303
  emitDevtools({ type: 'worker:respawn', poolId: this.poolId, slot: this.slots.length - 1 });
304
+ this.lifecycle('respawn', this.slots.length - 1);
296
305
  } else if (this.slots.length === 0) {
297
306
  // No workers left to ever serve the queue.
298
307
  for (const entry of this.queue.splice(0)) {
@@ -495,6 +504,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
495
504
 
496
505
  public terminate(): void {
497
506
  this.closed = true;
507
+ this.lifecycle('terminate');
498
508
  this.devtoolsOff?.();
499
509
  // Final flush + release — fire-and-forget; terminate() stays synchronous.
500
510
  void this.persistence?.stop()?.catch?.((e: unknown) =>