@atolljs/core 0.1.6 → 0.1.8

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 (59) hide show
  1. package/README.md +1 -1
  2. package/dist/contract/memoryDevtools.d.ts +30 -0
  3. package/dist/contract/sharedMemory.d.ts +6 -2
  4. package/dist/contract/types.d.ts +13 -0
  5. package/dist/devtools.d.ts +346 -0
  6. package/dist/index.d.ts +8 -2
  7. package/dist/index.js +19 -16
  8. package/dist/index10.js +28 -184
  9. package/dist/index11.js +18 -48
  10. package/dist/index12.js +29 -63
  11. package/dist/index13.js +49 -24
  12. package/dist/index14.js +57 -59
  13. package/dist/index15.js +335 -45
  14. package/dist/index16.js +242 -47
  15. package/dist/index17.js +57 -38
  16. package/dist/index18.js +9 -86
  17. package/dist/index19.js +58 -48
  18. package/dist/index20.js +24 -56
  19. package/dist/index21.js +61 -0
  20. package/dist/index22.js +48 -0
  21. package/dist/index23.js +68 -0
  22. package/dist/index24.js +41 -0
  23. package/dist/index25.js +46 -0
  24. package/dist/index26.js +113 -0
  25. package/dist/index27.js +60 -0
  26. package/dist/index28.js +57 -0
  27. package/dist/index5.js +227 -35
  28. package/dist/index6.js +143 -471
  29. package/dist/index7.js +51 -20
  30. package/dist/index8.js +507 -27
  31. package/dist/index9.js +19 -18
  32. package/dist/pool/dedicatedWorker.d.ts +63 -0
  33. package/dist/pool/devtoolsCommands.d.ts +54 -0
  34. package/dist/pool/subWorkerClient.d.ts +50 -0
  35. package/dist/pool/workerChannel.d.ts +44 -0
  36. package/dist/pool/workerClient.d.ts +38 -8
  37. package/dist/pool/workerPool.d.ts +13 -0
  38. package/dist/reactive.d.ts +15 -0
  39. package/dist/reactiveGraph.d.ts +9 -0
  40. package/dist/userTiming.d.ts +27 -0
  41. package/package.json +2 -1
  42. package/src/contract/memoryDevtools.ts +249 -0
  43. package/src/contract/sharedMemory.ts +39 -9
  44. package/src/contract/types.ts +13 -0
  45. package/src/devtools.ts +735 -0
  46. package/src/index.ts +27 -1
  47. package/src/log.ts +22 -0
  48. package/src/observable.ts +16 -3
  49. package/src/pool/dedicatedWorker.ts +332 -0
  50. package/src/pool/devtoolsCommands.ts +165 -0
  51. package/src/pool/subWorkerClient.ts +70 -0
  52. package/src/pool/workerChannel.ts +99 -0
  53. package/src/pool/workerClient.ts +64 -15
  54. package/src/pool/workerPool.ts +115 -36
  55. package/src/reactive.ts +87 -12
  56. package/src/reactiveGraph.ts +91 -0
  57. package/src/testing/inProcessWorker.ts +4 -3
  58. package/src/userTiming.ts +71 -0
  59. package/src/worker/workerBootstrap.ts +29 -3
@@ -0,0 +1,63 @@
1
+ import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory.js';
2
+ import type { MemoryPersistence, TaskContract, TaskMap, WorkerPoolConfig } from '../contract/types.js';
3
+ import type { PoolStats, RunOptions } from './workerPool.js';
4
+ /**
5
+ * Config for a single dedicated worker: the WorkerPool config minus the
6
+ * pool-only knobs (size, per-worker concurrency cap, queue bound, task
7
+ * methods). Every call posts straight to the worker.
8
+ */
9
+ export type DedicatedWorkerConfig<S extends SharedSpec = SharedSpec> = Omit<WorkerPoolConfig<S, TaskMap>, 'poolSize' | 'concurrency' | 'maxQueue' | 'tasks'>;
10
+ /**
11
+ * One worker, no pool: what `connectWorker({ workers: 1 })` (the default)
12
+ * builds. Calls are posted to the worker immediately, with no queue and no
13
+ * scheduler; the worker's own message loop runs them in arrival order (a
14
+ * task that awaits lets the next message start, exactly as in a pool slot
15
+ * with a raised concurrency cap). Kept from the pool: per-call
16
+ * timeout/abort, crash respawn, `stats()`, and the same devtools events,
17
+ * with `pool:init` flagged `dedicated: true`.
18
+ */
19
+ export declare class DedicatedWorker<S extends SharedSpec = SharedSpec> {
20
+ /**
21
+ * Devtools correlation id: '<name>-w' when the config has a `name`, else
22
+ * 'worker-N'. The same role a pool's id plays.
23
+ */
24
+ readonly poolId: string;
25
+ readonly sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
26
+ readonly persistence: MemoryPersistence | undefined;
27
+ private readonly mem;
28
+ private readonly spawn;
29
+ private readonly respawn;
30
+ private readonly taskTimeout?;
31
+ private readonly label?;
32
+ private worker;
33
+ private pending;
34
+ private nextMessageId;
35
+ private nextCallId;
36
+ private closed;
37
+ /** Dashboard fault injection (`pool.chaos`); null unless devtools set it. */
38
+ private chaos;
39
+ private devtoolsOff?;
40
+ private drainWaiters;
41
+ private statsAgg;
42
+ constructor(config: DedicatedWorkerConfig<S>);
43
+ private attach;
44
+ private detach;
45
+ private handleReply;
46
+ private handleError;
47
+ private rejectPending;
48
+ /** Abort/timeout: the caller rejects now; the worker's eventual reply is discarded. */
49
+ private cancel;
50
+ private checkDrained;
51
+ /** Post the call straight to the worker — no queue, no scheduler. */
52
+ dispatch<Args extends any[], Result>(contract: TaskContract<Args, Result>, args: Args, options?: RunOptions): Promise<Result>;
53
+ runTask<Args extends any[], Result>(contract: TaskContract<Args, Result>, ...args: Args): Promise<Result>;
54
+ /** The shared buffer handed to the worker via INIT_MEMORY; undefined when message-only. */
55
+ get sharedBuffer(): SharedArrayBuffer | undefined;
56
+ /** The worker, as a one-element snapshot (same shape as `WorkerPool.workers`). */
57
+ get workers(): readonly Worker[];
58
+ /** Same shape as `WorkerPool.stats()`; `queued` and `waitMs` stay zero (there is no queue). */
59
+ stats(): PoolStats;
60
+ /** Resolves once nothing is in flight, then terminates. */
61
+ close(): Promise<void>;
62
+ terminate(): void;
63
+ }
@@ -0,0 +1,54 @@
1
+ import type { PoolStats } from './workerPool.js';
2
+ /** Fault injection applied at dispatch. All rates are probabilities in 0..1. */
3
+ export interface ChaosConfig {
4
+ /** Delay before the call is posted to the worker. */
5
+ delayMs?: number;
6
+ /** Share of calls rejected with `CHAOS_FAILURE` (settle outcome 'error'). */
7
+ failRate?: number;
8
+ /** Share of calls failed through the timeout path (settle outcome 'timeout'). */
9
+ timeoutRate?: number;
10
+ }
11
+ /** Error message of a chaos-injected failure. */
12
+ export declare const CHAOS_FAILURE = "chaos: injected failure";
13
+ /** Crash message of a dashboard `worker.kill`. */
14
+ export declare const KILL_MESSAGE = "killed from devtools";
15
+ /** What a runner exposes to the dashboard commands. */
16
+ export interface DevtoolsRunnerHandle {
17
+ poolId: string;
18
+ label?: string;
19
+ dedicated: boolean;
20
+ /** Live worker count. */
21
+ size(): number;
22
+ stats(): PoolStats;
23
+ /** Run the runner's real crash handler for that slot (throws on a bad slot). */
24
+ kill(slot: number): void;
25
+ getChaos(): ChaosConfig | null;
26
+ setChaos(chaos: ChaosConfig | null): void;
27
+ }
28
+ /** Validate `pool.chaos` args into a config; null when nothing is active (clears). */
29
+ export declare const parseChaos: (args: Record<string, unknown>) => ChaosConfig | null;
30
+ /**
31
+ * Register a live runner; returns its unregister. The pool commands are
32
+ * installed with the first runner and removed with the last, so the
33
+ * dashboard's `devtools.commands` only advertises them while there is
34
+ * something to control.
35
+ */
36
+ export declare const registerDevtoolsRunner: (handle: DevtoolsRunnerHandle) => (() => void);
37
+ /** Hooks a runner supplies to `applyChaos` for one dispatched call. */
38
+ export interface ChaosCall {
39
+ /** Still awaiting its reply (not crashed/terminated away during the delay). */
40
+ live(): boolean;
41
+ /** Settle as a worker-reported failure (the runner's reply path). */
42
+ fail(): void;
43
+ /** Settle through the runner's timeout path. */
44
+ timeout(): void;
45
+ /** Post the call to the worker. */
46
+ post(): void;
47
+ }
48
+ /**
49
+ * Apply an active chaos config to one dispatched call. An injected failure
50
+ * never reaches the worker; an injected timeout settles the caller through
51
+ * the timeout path and still posts the call, exactly like a real timeout
52
+ * (the worker runs it; its late reply frees the slot and is discarded).
53
+ */
54
+ export declare const applyChaos: (chaos: ChaosConfig, call: ChaosCall) => void;
@@ -0,0 +1,50 @@
1
+ import type { SharedSpec } from '../contract/sharedMemory.js';
2
+ import type { WorkerDefinition } from '../worker/defineWorker.js';
3
+ import { type ConnectWorkerConfig, type WorkerClient } from './workerClient.js';
4
+ /**
5
+ * Worker-shell side of a nested pool: identical options to
6
+ * {@link ConnectWorkerConfig}, since the pool protocol (INIT / INIT_MEMORY /
7
+ * EXECUTE_TASK) doesn't care which side of the thread boundary spawned the
8
+ * workers. The distinguishing knob is `sharedBuffer` — inside a worker
9
+ * shell, a bound contract's `.buffer` (or the shell pool's `sharedBuffer`)
10
+ * lets sub-workers attach to the SAME shared memory instead of a fresh one.
11
+ */
12
+ export interface ConnectSubWorkerConfig<S extends SharedSpec> extends ConnectWorkerConfig<S> {
13
+ }
14
+ /**
15
+ * Spawn a worker pool from INSIDE a worker — the worker-shell counterpart
16
+ * of {@link connectWorker}. The sub-worker entry is a normal `defineWorker`
17
+ * module; only the spawn site differs:
18
+ *
19
+ * // shell.worker.ts — runs inside a dedicated worker
20
+ * const sub = connectSubWorker<SubWorker>({
21
+ * worker: () => new Worker(new URL('./sub.worker.ts', import.meta.url), { type: 'module' }),
22
+ * sharedMemory: memory, // fresh buffer per sub-pool
23
+ * // — or attach sub-workers to THIS worker's bound buffer:
24
+ * // sharedBuffer: memory.buffer,
25
+ * workers: 2,
26
+ * });
27
+ *
28
+ * methods: { fanOut: (chunks) => Promise.all(chunks.map((c) => sub.crunch(c))) }
29
+ *
30
+ * Memory modes (both supported, pick per config):
31
+ * - `sharedMemory` without `sharedBuffer`: the sub-pool allocates its own
32
+ * WebAssembly.Memory inside the worker and binds the contract to it —
33
+ * a private buffer for this tier.
34
+ * - `sharedBuffer: someContract.buffer`: no allocation — every sub-worker
35
+ * binds the shell's existing buffer, so all three tiers (main, shell,
36
+ * sub) read and write the same fields. Hub-and-spoke sharing.
37
+ *
38
+ * Nested workers need `new Worker` inside a worker context: Chrome and
39
+ * Firefox support it, Safari does not — feature-detect and fall back to
40
+ * dispatching on the main-thread pool. Node's `worker_threads` nests
41
+ * natively; use `worker: () => createNodeWorker(new Worker('./sub.js'))`
42
+ * (a sub-worker entry still needs `@atolljs/node/shim` first).
43
+ *
44
+ * Bundlers detect `new Worker(new URL(...))` inside a worker bundle too:
45
+ * the @atolljs/vite dev plugin rewrites nested entry URLs back onto its
46
+ * `?worker_file` bundling path, and `vite build` emits nested worker
47
+ * chunks. The same rules as the main thread apply — the URL must stay an
48
+ * inline literal, never hoisted or computed.
49
+ */
50
+ export declare function connectSubWorker<W extends WorkerDefinition, S extends SharedSpec = W extends WorkerDefinition<infer X> ? X : SharedSpec>(config: ConnectSubWorkerConfig<S>): WorkerClient<W, S>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The per-worker plumbing both runners share: `WorkerPool` (N workers,
3
+ * queue + least-busy scheduling) and `DedicatedWorker` (one worker, no
4
+ * pool). Memory setup, the INIT handshake, and devtools forwarding are the
5
+ * same protocol whichever runner owns the worker.
6
+ */
7
+ import { MemoryManager } from './memory.js';
8
+ import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory.js';
9
+ import type { MemoryPersistence, TaskMap, WorkerPoolConfig } from '../contract/types.js';
10
+ import { type EmittedDevtoolsEvent } from '../devtools.js';
11
+ export interface ChannelMemory<S extends SharedSpec> {
12
+ sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
13
+ memoryManager?: MemoryManager;
14
+ /** A caller-supplied buffer shipped via INIT_MEMORY (sub-worker / second-runner sharing). */
15
+ injectedBuffer?: SharedArrayBuffer;
16
+ persistence: MemoryPersistence | undefined;
17
+ }
18
+ /**
19
+ * Resolve the runner's memory once, at construction: a caller-supplied
20
+ * buffer binds the contract to those bytes, otherwise a fresh
21
+ * WebAssembly.Memory is allocated. Message-only runners get neither.
22
+ */
23
+ export declare function setupChannelMemory<S extends SharedSpec>(config: WorkerPoolConfig<S, TaskMap>, owner: string): ChannelMemory<S>;
24
+ /**
25
+ * The INIT handshake: INIT_MEMORY with the runner's memory (or the injected
26
+ * buffer), bare INIT for message-only runners. `devtools` tells the worker
27
+ * to forward its instrumentation back only when a sink is live at spawn.
28
+ */
29
+ export declare function sendInit<S extends SharedSpec>(worker: Worker, mem: ChannelMemory<S>): 'INIT' | 'INIT_MEMORY';
30
+ /**
31
+ * Re-emit a worker-forwarded devtools event into this thread's sink.
32
+ *
33
+ * A runner constructed inside a worker mints ids ('pool-1', 'worker-1') on
34
+ * that context's own counters, which collide textually with ids minted on
35
+ * the parent thread. Every id arriving from a worker refers to the worker's
36
+ * local id space, so it is qualified unconditionally as
37
+ * `${runnerId}#${slot}~${id}`: the id then encodes the spawn path and
38
+ * dashboards can draw parent→child edges. Existing `worker` stamps
39
+ * (multi-hop forwarding) are qualified the same way so attribution stays
40
+ * pinned to the deepest emitting worker.
41
+ */
42
+ export declare function forwardWorkerDevtools(runnerId: string, slot: number, ev: EmittedDevtoolsEvent & {
43
+ poolId?: string;
44
+ }): void;
@@ -2,6 +2,7 @@ import type { ServiceMethod, TaskRunner } from '../service.js';
2
2
  import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory.js';
3
3
  import type { MemoryConfig, Prettify } from '../contract/types.js';
4
4
  import { WorkerPool, type RunOptions } from './workerPool.js';
5
+ import { DedicatedWorker } from './dedicatedWorker.js';
5
6
  import type { WorkerDefinition, WorkerMethodMap } from '../worker/defineWorker.js';
6
7
  /** Maps a worker method's declared type to its async client signature. */
7
8
  type Sig<F> = F extends ServiceMethod<infer A extends any[], infer R> ? (...args: A) => Promise<R> : F extends {
@@ -31,16 +32,33 @@ export declare function workerClient<W extends WorkerDefinition>(runner: TaskRun
31
32
  export interface ConnectWorkerConfig<S extends SharedSpec> {
32
33
  /** Omit for a message-only pool — the SharedArrayBuffer check is skipped. */
33
34
  sharedMemory?: SharedMemory<S> & Prettify<SharedAccess<S>>;
35
+ /**
36
+ * Ship an existing SharedArrayBuffer to every worker instead of
37
+ * allocating one — e.g. `sharedBuffer: anotherPool.sharedBuffer`, or a
38
+ * bound contract's `contract.buffer` inside a worker shell. A thunk is
39
+ * resolved once at pool construction.
40
+ */
41
+ sharedBuffer?: SharedArrayBuffer | (() => SharedArrayBuffer | undefined);
34
42
  /**
35
43
  * Bundler-detectable factory `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })`,
36
44
  * or a URL.
37
45
  */
38
46
  worker: (() => Worker) | URL;
47
+ /**
48
+ * How many workers this client needs. `1` (the default) is a single
49
+ * dedicated worker with no pool: calls post straight to it, no queue or
50
+ * scheduler. `N > 1` builds a pool that spreads separate calls across N
51
+ * workers; `'auto'` sizes it to `navigator.hardwareConcurrency ?? 4`.
52
+ */
53
+ workers?: number | 'auto';
54
+ /** @deprecated Use `workers`. Read only when `workers` is absent. */
39
55
  poolSize?: number | 'auto';
56
+ /** Display label for devtools/dashboards — falls back to a generated id. */
57
+ name?: string;
40
58
  memory?: MemoryConfig;
41
- /** Max in-flight tasks per worker (default 1); excess calls queue FIFO. */
59
+ /** Pool only: max in-flight tasks per worker (default 1); excess calls queue FIFO. */
42
60
  concurrency?: number;
43
- /** Max queued calls (default Infinity); a full queue rejects with PoolQueueFullError. */
61
+ /** Pool only: max queued calls (default Infinity); a full queue rejects with PoolQueueFullError. */
44
62
  maxQueue?: number;
45
63
  /** Default true — a crashed worker is replaced and its in-flight calls reject. */
46
64
  respawn?: boolean;
@@ -49,13 +67,22 @@ export interface ConnectWorkerConfig<S extends SharedSpec> {
49
67
  /** Default true: pool spawns on first method call (or start()). False spawns immediately. */
50
68
  lazy?: boolean;
51
69
  }
52
- /** A typed worker client plus pool lifecycle controls. */
70
+ /** What a client runs on: a pool (`workers > 1`) or one dedicated worker. */
71
+ export type WorkerRunner<S extends SharedSpec = SharedSpec> = WorkerPool<S> | DedicatedWorker<S>;
72
+ /** Resolve a `workers` count: `'auto'` is one per core (4 when unknown). */
73
+ export declare const resolveWorkerCount: (n: number | 'auto' | undefined) => number;
74
+ /** A typed worker client plus lifecycle controls. */
53
75
  export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = WorkerMethods<W> & {
54
- /** Spawn the pool now (idempotent). */
76
+ /** Spawn the worker(s) now (idempotent). */
55
77
  start(): void;
56
- /** Terminate the pool; the next method call re-spawns it. */
78
+ /** Terminate the worker(s); the next method call re-spawns. */
57
79
  terminate(): void;
58
- readonly pool: WorkerPool<S> | null;
80
+ /**
81
+ * The runner once spawned: a `DedicatedWorker` for `workers: 1`, a
82
+ * `WorkerPool` otherwise. Both expose `stats()`, `close()`, `terminate()`,
83
+ * `workers`, `sharedBuffer` and `poolId`.
84
+ */
85
+ readonly pool: WorkerRunner<S> | null;
59
86
  readonly sharedMemory: ConnectWorkerConfig<S>['sharedMemory'] | undefined;
60
87
  };
61
88
  /**
@@ -67,12 +94,15 @@ export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = Wor
67
94
  * export const incidents = connectWorker<IncidentsWorker>({
68
95
  * sharedMemory: incidentsMemory,
69
96
  * worker: () => new Worker(new URL('./worker/incidents.worker.ts', import.meta.url), { type: 'module' }),
70
- * poolSize: 1,
97
+ * workers: 3, // omit for one dedicated worker
71
98
  * });
72
99
  *
73
- * await incidents.queryIncidents(q); // typed dispatch to the pool
100
+ * await incidents.queryIncidents(q); // typed dispatch
74
101
  * incidents.terminate(); // next call lazily re-spawns
75
102
  *
103
+ * `workers` states what the work needs and the client picks the runner:
104
+ * 1 (default) → a `DedicatedWorker`, no pool; more → a `WorkerPool`.
105
+ *
76
106
  * Lazy by default: no Worker is touched until the first method call (or
77
107
  * `start()`), so importing the client is SSR-safe.
78
108
  */
@@ -42,7 +42,10 @@ declare class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
42
42
  private pending;
43
43
  private nextMessageId;
44
44
  private queue;
45
+ private readonly mem;
45
46
  private memoryManager?;
47
+ /** A caller-supplied buffer shipped via INIT_MEMORY (sub-worker / second-pool sharing). */
48
+ private injectedBuffer?;
46
49
  private workerUrl?;
47
50
  private spawn;
48
51
  private readonly concurrency;
@@ -50,8 +53,18 @@ declare class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
50
53
  private readonly respawn;
51
54
  private readonly taskTimeout?;
52
55
  private closed;
56
+ /** Dashboard fault injection (`pool.chaos`); null unless devtools set it. */
57
+ private chaos;
58
+ private devtoolsOff?;
53
59
  private drainWaiters;
54
60
  private statsAgg;
61
+ /**
62
+ * Devtools correlation id, stamped on every event this pool emits:
63
+ * '<name>-p' when the config has a `name`, else 'pool-N'.
64
+ */
65
+ readonly poolId: string;
66
+ private readonly label?;
67
+ private nextCallId;
55
68
  readonly sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
56
69
  /** The attached memory-persistence adapter, if the config supplied one. */
57
70
  readonly persistence: MemoryPersistence | undefined;
@@ -44,3 +44,18 @@ export declare function reactive<T>(connector: Connector<T>): ReactiveConnector<
44
44
  */
45
45
  export declare function watch<T>(connector: Connector<T>, onChange: (value: T) => void): () => void;
46
46
  export declare function watch<T, S>(connector: Connector<T>, select: (value: T) => S, onChange: (slice: S, previous: S | undefined) => void, options?: SliceOptions<S>): () => void;
47
+ /** Graph labelling for {@link watchNodes} — `observe()` names its effect. */
48
+ export interface WatchMeta {
49
+ /** A real selector was given (draws a derived node), not the identity. */
50
+ sliced: boolean;
51
+ /** Effect node label prefix: 'watch', 'observe'. */
52
+ label: string;
53
+ }
54
+ /**
55
+ * `watch()` plus its devtools graph wiring: bridge → [derived slice] →
56
+ * effect. Internal: `observe()` uses it to own the effect node's label.
57
+ */
58
+ export declare function watchNodes<T, S>(connector: Connector<T>, select: (value: T) => S, cb: (slice: S, previous: S | undefined) => void, options: SliceOptions<S> | undefined, meta: WatchMeta): {
59
+ stop: () => void;
60
+ effect: string | null;
61
+ };
@@ -0,0 +1,9 @@
1
+ export type ReactiveKind = 'source' | 'derived' | 'effect' | 'bridge';
2
+ /** The source node for a shared-memory field — emitted once per thread. */
3
+ export declare const graphSource: (path: string | undefined) => string | null;
4
+ /** Create a node; returns its id, or null while devtools is off. */
5
+ export declare const graphNode: (kind: ReactiveKind, label: string, deps: (string | null)[]) => string | null;
6
+ /** Re-emit a node with a new label (same id — the dashboard replaces it). */
7
+ export declare const relabelNode: (id: string | null, label: string) => void;
8
+ /** Mark nodes disposed (null ids are skipped). */
9
+ export declare const disposeNodes: (...ids: (string | null)[]) => void;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * User Timing bridge: `performance.measure` entries for atoll work, so the
3
+ * browser's own Performance panel shows tasks next to the app's frames.
4
+ * Callers gate on `devtoolsEnabled()`; this module never checks it itself.
5
+ *
6
+ * `detail.devtools` is Chrome's extensibility payload: entries land on a
7
+ * custom track inside an 'atoll' track group. Other engines ignore the
8
+ * detail and record a plain measure.
9
+ */
10
+ /** Chrome's extensibility palette names. */
11
+ export type TimingColor = 'primary' | 'primary-light' | 'secondary' | 'tertiary' | 'error' | 'warning';
12
+ export interface TimingTrack {
13
+ /** Track label inside the 'atoll' group, e.g. a pool id. */
14
+ track: string;
15
+ color?: TimingColor;
16
+ properties?: [string, string | number][];
17
+ tooltipText?: string;
18
+ }
19
+ /**
20
+ * Record `[start, end]` (performance.now() ms on this thread) as `name`.
21
+ * The entry is cleared from the timeline buffer right away: the Performance
22
+ * panel captures it at measure time, and per-task measures would otherwise
23
+ * grow the buffer without bound (and trip Node's buffer-size warning).
24
+ */
25
+ export declare const measureAtoll: (name: string, start: number, end: number, t: TimingTrack) => void;
26
+ /** Main-thread span of one call, dispatch → settle, on the runner's track. */
27
+ export declare const measureTaskCall: (runnerId: string, taskId: string, callId: number, slot: number, start: number, end: number, ok: boolean) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolljs/core",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -38,6 +38,7 @@
38
38
  "stats": "node scripts/bundle-stats.mjs",
39
39
  "stats:islands": "node scripts/island-perf.mjs",
40
40
  "build:pages": "node scripts/build-pages.mjs",
41
+ "build:extension": "node packages/devtools-extension/scripts/build.mjs",
41
42
  "assemble:pages": "node scripts/assemble-pages.mjs",
42
43
  "serve:pages": "node scripts/serve-pages.mjs",
43
44
  "serve:static": "node scripts/serve-static.mjs"
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Dashboard commands over shared memory: `memory.read` (live values) and
3
+ * watchpoints (`memory.watch` / `memory.unwatch` / `memory.watches`).
4
+ * Registered by `SharedMemory.bind()` the first time a contract binds
5
+ * while devtools is enabled, so apps without devtools never load them
6
+ * into the command table.
7
+ *
8
+ * Watchpoints fire `memory:watch-hit` from two places on the thread that
9
+ * set them (the main thread: commands arrive over its transport):
10
+ * - the connector write path, synchronously, for writes made on this
11
+ * thread (guarded by `memoryWatches.size`, behind the existing
12
+ * `devtoolsEnabled()` check: zero cost with no watches);
13
+ * - a version observer on each watched field, for writes from other
14
+ * threads (workers share the buffer, so the main thread reads their
15
+ * values). Remote bursts between two observations coalesce: only the
16
+ * latest value is checked.
17
+ */
18
+ import { emitDevtools, previewValue, registerDevtoolsCommand } from '../devtools';
19
+ import type { Connector, SharedMemory } from './sharedMemory';
20
+
21
+ export type WatchRule = { text: string; test: (value: unknown) => boolean };
22
+
23
+ interface WatchState {
24
+ rule: WatchRule;
25
+ /** Per contract: last version checked and last value preview ('change'). */
26
+ seen: WeakMap<SharedMemory<any>, { version: number; preview: string }>;
27
+ stop: () => void;
28
+ hits: number;
29
+ }
30
+
31
+ /** path → watch. Read by the write path — keep `.size` the only hot check. */
32
+ export const memoryWatches = new Map<string, WatchState>();
33
+
34
+ let contractsOf: () => readonly SharedMemory<any>[] = () => [];
35
+ let registered = false;
36
+
37
+ /* ── value previews ──────────────────────────────────────────────────────── */
38
+
39
+ const LIST_HEAD = 5;
40
+ const ARRAY_HEAD = 16;
41
+
42
+ /** Bounded preview of a field's current value — lists/typed arrays read only a head. */
43
+ export const fieldPreview = (connector: Connector<any>): string => {
44
+ const list = connector as Connector<any> & { readAt?: (i: number) => unknown; recordCount?: number };
45
+ if (typeof list.readAt === 'function' && typeof list.recordCount === 'number') {
46
+ const head = Array.from({ length: Math.min(LIST_HEAD, list.recordCount) }, (_, i) => list.readAt!(i));
47
+ return previewValue({ records: list.recordCount, head }, 1024);
48
+ }
49
+ const value = connector.read();
50
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
51
+ const arr = value as unknown as ArrayLike<number | bigint>;
52
+ const head = Array.from({ length: Math.min(ARRAY_HEAD, arr.length) }, (_, i) =>
53
+ typeof arr[i] === 'bigint' ? `${arr[i]}n` : arr[i],
54
+ );
55
+ return `${value.constructor.name}(${arr.length}) ${JSON.stringify(head)}${arr.length > ARRAY_HEAD ? '…' : ''}`;
56
+ }
57
+ return previewValue(value, 1024);
58
+ };
59
+
60
+ /** Path lookup without the typed flat-key overload. */
61
+ const conn = (memory: SharedMemory<any>, path: string): Connector<any> =>
62
+ (memory as unknown as { connector(p: string): Connector<any> }).connector(path);
63
+
64
+ const versionOf = (connector: Connector<any>): number =>
65
+ connector._version ? Atomics.load(connector._version.view, connector._version.index) : 0;
66
+
67
+ /* ── rules ───────────────────────────────────────────────────────────────── */
68
+
69
+ const parseLiteral = (s: string): unknown => {
70
+ try {
71
+ return JSON.parse(s);
72
+ } catch {
73
+ return s;
74
+ }
75
+ };
76
+
77
+ const asNumber = (v: unknown): number | null =>
78
+ typeof v === 'number' ? v : typeof v === 'bigint' ? Number(v) : null;
79
+
80
+ /**
81
+ * Parse a watch rule: 'change' | '> n' | '< n' | '>= n' | '<= n' | '== v' |
82
+ * '!= v'. `v` is JSON when it parses ('"down"', 'true', '3'), else a bare
83
+ * string. Numeric comparisons only match number/bigint fields.
84
+ */
85
+ export const parseWatchRule = (raw: string): WatchRule => {
86
+ const text = raw.trim();
87
+ if (text === 'change') return { text, test: () => true };
88
+ const m = /^(>=|<=|==|!=|>|<)\s*(.+)$/.exec(text);
89
+ if (!m) throw new Error(`memory.watch: bad rule '${raw}' — use change, > n, < n, >= n, <= n, == v, != v`);
90
+ const [, op, operand] = m;
91
+ if (op === '==' || op === '!=') {
92
+ const lit = parseLiteral(operand);
93
+ const eq = (v: unknown) =>
94
+ v !== null && typeof v === 'object' ? previewValue(v) === previewValue(lit) : Object.is(v, lit) || (typeof v === 'bigint' && Number(v) === lit);
95
+ return { text, test: op === '==' ? eq : (v) => !eq(v) };
96
+ }
97
+ const n = Number(operand);
98
+ if (!Number.isFinite(n)) throw new Error(`memory.watch: '${operand}' is not a number`);
99
+ const cmp: Record<string, (a: number) => boolean> = {
100
+ '>': (a) => a > n, '<': (a) => a < n, '>=': (a) => a >= n, '<=': (a) => a <= n,
101
+ };
102
+ return {
103
+ text,
104
+ test: (v) => {
105
+ const a = asNumber(v);
106
+ return a !== null && cmp[op](a);
107
+ },
108
+ };
109
+ };
110
+
111
+ /* ── matching ────────────────────────────────────────────────────────────── */
112
+
113
+ const check = (memory: SharedMemory<any>, path: string, w: WatchState, connector: Connector<any>, version: number): void => {
114
+ const last = w.seen.get(memory);
115
+ if (last && last.version >= version) return;
116
+ const change = w.rule.text === 'change';
117
+ // List fields only support 'change' — read() would decode every record.
118
+ const isList = typeof (connector as { readAt?: unknown }).readAt === 'function';
119
+ let value: unknown;
120
+ let preview: string;
121
+ try {
122
+ preview = fieldPreview(connector);
123
+ value = change || isList ? undefined : connector.read();
124
+ } catch {
125
+ return;
126
+ }
127
+ w.seen.set(memory, { version, preview });
128
+ const hit = change ? preview !== last?.preview : !isList && w.rule.test(value);
129
+ if (!hit) return;
130
+ w.hits++;
131
+ emitDevtools({ type: 'memory:watch-hit', path, version, value: preview, rule: w.rule.text });
132
+ };
133
+
134
+ /** Write-path hook — only called when `memoryWatches.size > 0`. */
135
+ export const checkWatchOnWrite = (memory: SharedMemory<any>, path: string, version: number): void => {
136
+ const w = memoryWatches.get(path);
137
+ if (!w) return;
138
+ check(memory, path, w, conn(memory, path), version);
139
+ };
140
+
141
+ /**
142
+ * Observe the field's version counter on this thread so other threads'
143
+ * writes are checked too. Re-resolves the connector each wake (contracts
144
+ * can rebind to another buffer) and waits with a timeout so a rebind is
145
+ * picked up without a write. Falls back to a 50ms poll without waitAsync.
146
+ */
147
+ const observeRemote = (path: string, w: WatchState): (() => void) => {
148
+ let stopped = false;
149
+ const sweep = (): void => {
150
+ for (const memory of contractsOf()) {
151
+ if (!memory.bound) continue;
152
+ let connector: Connector<any>;
153
+ try {
154
+ connector = conn(memory, path);
155
+ } catch {
156
+ continue;
157
+ }
158
+ check(memory, path, w, connector, versionOf(connector));
159
+ }
160
+ };
161
+ if (typeof Atomics.waitAsync === 'function') {
162
+ void (async () => {
163
+ while (!stopped) {
164
+ const first = contractsOf().find((m) => {
165
+ try {
166
+ return m.bound && conn(m, path)._version !== undefined;
167
+ } catch {
168
+ return false;
169
+ }
170
+ });
171
+ const slot = first ? conn(first, path)._version : undefined;
172
+ if (slot) {
173
+ const res = Atomics.waitAsync(slot.view, slot.index, Atomics.load(slot.view, slot.index), 250);
174
+ if (res.async) await res.value;
175
+ } else {
176
+ await new Promise((r) => setTimeout(r, 250));
177
+ }
178
+ if (!stopped) sweep();
179
+ }
180
+ })();
181
+ return () => { stopped = true; };
182
+ }
183
+ const timer = setInterval(sweep, 50);
184
+ (timer as unknown as { unref?: () => void }).unref?.();
185
+ return () => {
186
+ stopped = true;
187
+ clearInterval(timer);
188
+ };
189
+ };
190
+
191
+ /* ── commands ────────────────────────────────────────────────────────────── */
192
+
193
+ const watchList = () =>
194
+ [...memoryWatches.entries()].map(([path, w]) => ({ path, rule: w.rule.text, hits: w.hits }));
195
+
196
+ /** Register the `memory.*` commands once; `contracts` lists every defined contract. */
197
+ export const ensureMemoryCommands = (contracts: () => readonly SharedMemory<any>[]): void => {
198
+ contractsOf = contracts;
199
+ if (registered) return;
200
+ registered = true;
201
+
202
+ registerDevtoolsCommand('memory.read', () => {
203
+ const out: { path: string; value: string; version: number; contract: number }[] = [];
204
+ contractsOf().forEach((memory, contract) => {
205
+ if (!memory.bound) return;
206
+ for (const { path } of memory.fields()) {
207
+ try {
208
+ const connector = conn(memory, path);
209
+ out.push({ path, value: fieldPreview(connector), version: versionOf(connector), contract });
210
+ } catch (err) {
211
+ out.push({ path, value: `[unreadable: ${(err as Error).message}]`, version: -1, contract });
212
+ }
213
+ }
214
+ });
215
+ return out;
216
+ });
217
+
218
+ registerDevtoolsCommand('memory.watch', (args) => {
219
+ const path = String(args.path ?? '');
220
+ if (!path) throw new Error('memory.watch: `path` is required');
221
+ const known = contractsOf().some((m) => m.fields().some((f) => f.path === path));
222
+ if (!known) throw new Error(`memory.watch: no contract defines a field '${path}'`);
223
+ const rule = parseWatchRule(String(args.rule ?? 'change'));
224
+ memoryWatches.get(path)?.stop();
225
+ const w: WatchState = { rule, seen: new WeakMap(), stop: () => {}, hits: 0 };
226
+ // Baseline: the current value/version is not a hit.
227
+ for (const memory of contractsOf()) {
228
+ if (!memory.bound) continue;
229
+ try {
230
+ const c = conn(memory, path);
231
+ w.seen.set(memory, { version: versionOf(c), preview: fieldPreview(c) });
232
+ } catch {
233
+ /* contract without this field */
234
+ }
235
+ }
236
+ memoryWatches.set(path, w);
237
+ w.stop = observeRemote(path, w);
238
+ return watchList();
239
+ });
240
+
241
+ registerDevtoolsCommand('memory.unwatch', (args) => {
242
+ const path = String(args.path ?? '');
243
+ memoryWatches.get(path)?.stop();
244
+ memoryWatches.delete(path);
245
+ return watchList();
246
+ });
247
+
248
+ registerDevtoolsCommand('memory.watches', () => watchList());
249
+ };