@atolljs/core 0.1.7 → 0.1.9
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.
- package/README.md +1 -1
- package/dist/contract/memoryDevtools.d.ts +30 -0
- package/dist/contract/sharedMemory.d.ts +4 -2
- package/dist/contract/types.d.ts +11 -0
- package/dist/devtools.d.ts +127 -4
- package/dist/index.d.ts +8 -4
- package/dist/index.js +19 -17
- package/dist/index10.js +29 -14
- package/dist/index11.js +14 -307
- package/dist/index12.js +29 -50
- package/dist/index13.js +46 -60
- package/dist/index14.js +56 -22
- package/dist/index15.js +332 -55
- package/dist/index16.js +246 -45
- package/dist/index17.js +56 -51
- package/dist/index18.js +9 -39
- package/dist/index19.js +56 -80
- package/dist/index20.js +24 -53
- package/dist/index21.js +56 -52
- package/dist/index22.js +48 -0
- package/dist/index23.js +68 -0
- package/dist/index24.js +41 -0
- package/dist/index25.js +46 -0
- package/dist/index26.js +113 -0
- package/dist/index27.js +60 -0
- package/dist/index28.js +57 -0
- package/dist/index5.js +135 -64
- package/dist/index6.js +160 -35
- package/dist/index7.js +45 -503
- package/dist/index8.js +513 -19
- package/dist/index9.js +19 -33
- package/dist/pool/dedicatedWorker.d.ts +63 -0
- package/dist/pool/devtoolsCommands.d.ts +54 -0
- package/dist/pool/subWorkerClient.d.ts +50 -0
- package/dist/pool/workerChannel.d.ts +44 -0
- package/dist/pool/workerClient.d.ts +36 -8
- package/dist/pool/workerPool.d.ts +10 -1
- package/dist/reactive.d.ts +15 -0
- package/dist/reactiveGraph.d.ts +9 -0
- package/dist/userTiming.d.ts +27 -0
- package/package.json +4 -3
- package/src/contract/memoryDevtools.ts +249 -0
- package/src/contract/sharedMemory.ts +20 -6
- package/src/contract/types.ts +11 -0
- package/src/devtools.ts +251 -6
- package/src/index.ts +13 -1
- package/src/log.ts +22 -0
- package/src/observable.ts +16 -3
- package/src/pool/dedicatedWorker.ts +332 -0
- package/src/pool/devtoolsCommands.ts +165 -0
- package/src/pool/subWorkerClient.ts +70 -0
- package/src/pool/workerChannel.ts +99 -0
- package/src/pool/workerClient.ts +61 -15
- package/src/pool/workerPool.ts +75 -45
- package/src/reactive.ts +87 -12
- package/src/reactiveGraph.ts +91 -0
- package/src/testing/inProcessWorker.ts +4 -3
- package/src/userTiming.ts +71 -0
- package/src/worker/workerBootstrap.ts +33 -5
|
@@ -2,6 +2,7 @@ import type { Prettify, Schema } from './types';
|
|
|
2
2
|
import { msgpackrCodec } from './msgpackrCodec';
|
|
3
3
|
import { memberToSpec, zodArrayInfo, zodObjectShape, zodStringBytes } from './listSchema';
|
|
4
4
|
import { devtoolsEnabled, emitDevtools } from '../devtools';
|
|
5
|
+
import { checkWatchOnWrite, ensureMemoryCommands, memoryWatches } from './memoryDevtools';
|
|
5
6
|
import { fmtBytes, scoped } from '../log';
|
|
6
7
|
import { z, fluent } from './zod';
|
|
7
8
|
|
|
@@ -144,6 +145,8 @@ export interface Connector<T> {
|
|
|
144
145
|
write(value: T): void;
|
|
145
146
|
/** Internal: slot in the shared version counter block, bumped on every write. */
|
|
146
147
|
readonly _version?: { view: Int32Array; index: number };
|
|
148
|
+
/** Internal: the field's contract path — devtools node labels. */
|
|
149
|
+
readonly _path?: string;
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
/* ------------------------------------------------------------------ */
|
|
@@ -811,6 +814,7 @@ export class SharedMemory<S extends SharedSpec = SharedSpec> {
|
|
|
811
814
|
memLog.debug(`bind "${path}" (${descriptor.kind}, ${fmtBytes(descriptor.byteLength)}) via ${this.plugins?.[descriptor.kind] ? 'plugin' : 'built-in'} factory`);
|
|
812
815
|
const ctx: ConnectorContext = { buffer, codec: this.codec, version: { view: versionView, index }, path };
|
|
813
816
|
const raw = factory(descriptor, ctx, this.offsets.get(path)!);
|
|
817
|
+
const commit = (raw as Partial<ListConnector<unknown>>).commit;
|
|
814
818
|
this.connectors.set(path, {
|
|
815
819
|
...raw,
|
|
816
820
|
write: (v) => {
|
|
@@ -822,12 +826,25 @@ export class SharedMemory<S extends SharedSpec = SharedSpec> {
|
|
|
822
826
|
Atomics.notify(versionView, index);
|
|
823
827
|
if (devtoolsEnabled()) {
|
|
824
828
|
emitDevtools({ type: 'memory:write', path, version: prev + 1 });
|
|
829
|
+
if (memoryWatches.size) checkWatchOnWrite(this, path, prev + 1);
|
|
825
830
|
}
|
|
826
831
|
},
|
|
832
|
+
// List commits bump inside the factory — wrapped for watchpoints only
|
|
833
|
+
// when devtools is on at bind time, so the plain path stays direct.
|
|
834
|
+
...(commit && devtoolsEnabled()
|
|
835
|
+
? {
|
|
836
|
+
commit: () => {
|
|
837
|
+
commit();
|
|
838
|
+
if (memoryWatches.size) checkWatchOnWrite(this, path, Atomics.load(versionView, index));
|
|
839
|
+
},
|
|
840
|
+
}
|
|
841
|
+
: {}),
|
|
827
842
|
_version: { view: versionView, index },
|
|
843
|
+
_path: path,
|
|
828
844
|
});
|
|
829
845
|
});
|
|
830
846
|
if (devtoolsEnabled()) {
|
|
847
|
+
ensureMemoryCommands(() => definedSharedMemories);
|
|
831
848
|
emitDevtools({
|
|
832
849
|
type: 'memory:bind',
|
|
833
850
|
fields: this.entries.map((e, i) => [i, e.path]),
|
|
@@ -870,15 +887,12 @@ export class SharedMemory<S extends SharedSpec = SharedSpec> {
|
|
|
870
887
|
}
|
|
871
888
|
|
|
872
889
|
/**
|
|
873
|
-
* Fires when the contract is bound on this thread —
|
|
874
|
-
* bound. Returns an unsubscribe function.
|
|
890
|
+
* Fires when the contract is bound on this thread — on every bind, and
|
|
891
|
+
* immediately if already bound. Returns an unsubscribe function.
|
|
875
892
|
*/
|
|
876
893
|
public onBound(cb: () => void): () => void {
|
|
877
|
-
if (this.isBound) {
|
|
878
|
-
cb();
|
|
879
|
-
return () => {};
|
|
880
|
-
}
|
|
881
894
|
this.boundListeners.add(cb);
|
|
895
|
+
if (this.isBound) cb();
|
|
882
896
|
return () => this.boundListeners.delete(cb);
|
|
883
897
|
}
|
|
884
898
|
|
package/src/contract/types.ts
CHANGED
|
@@ -57,6 +57,17 @@ export interface WorkerPoolConfig<S extends SharedSpec = SharedSpec, T extends T
|
|
|
57
57
|
* skipped and workers receive a plain `{ type: 'INIT' }` handshake.
|
|
58
58
|
*/
|
|
59
59
|
sharedMemory?: SharedMemory<S> & Prettify<SharedAccess<S>>;
|
|
60
|
+
/**
|
|
61
|
+
* Hand an existing SharedArrayBuffer to every spawned worker instead of
|
|
62
|
+
* allocating a fresh `WebAssembly.Memory` — the worker-shell case: a worker
|
|
63
|
+
* already bound to a contract spawns sub-workers on the SAME buffer
|
|
64
|
+
* (`sharedBuffer: contract.buffer`), or a second pool shares the first
|
|
65
|
+
* pool's `pool.sharedBuffer`. A thunk is resolved once, when the pool
|
|
66
|
+
* constructs. With `sharedMemory` the contract binds locally to this
|
|
67
|
+
* buffer; without it the buffer still ships via INIT_MEMORY and the
|
|
68
|
+
* workers bind their own defined contracts.
|
|
69
|
+
*/
|
|
70
|
+
sharedBuffer?: SharedArrayBuffer | (() => SharedArrayBuffer | undefined);
|
|
60
71
|
/**
|
|
61
72
|
* Persistence adapter for `sharedMemory` — invoked with the contract right
|
|
62
73
|
* after the pool binds it, and its `stop()` runs on `terminate()`.
|
package/src/devtools.ts
CHANGED
|
@@ -26,8 +26,14 @@ export type DevtoolsEvent =
|
|
|
26
26
|
/** Config `name`, when supplied — a display label, not the id. */
|
|
27
27
|
label?: string;
|
|
28
28
|
poolSize: number;
|
|
29
|
+
/** Per-worker in-flight cap; 0 for a dedicated worker (no queue, uncapped). */
|
|
29
30
|
concurrency: number;
|
|
30
31
|
memoryBytes?: number;
|
|
32
|
+
/**
|
|
33
|
+
* True for a single dedicated worker (`connectWorker({ workers: 1 })`,
|
|
34
|
+
* island clients): no pool, no queue. Same event vocabulary otherwise.
|
|
35
|
+
*/
|
|
36
|
+
dedicated?: boolean;
|
|
31
37
|
}
|
|
32
38
|
| { type: 'pool:terminate'; poolId: string }
|
|
33
39
|
| { type: 'worker:spawn'; poolId: string; slot: number }
|
|
@@ -41,6 +47,8 @@ export type DevtoolsEvent =
|
|
|
41
47
|
taskId: string;
|
|
42
48
|
slot: number;
|
|
43
49
|
waitMs: number;
|
|
50
|
+
/** Estimated structured-clone size of the task args (`estimateCloneBytes`). */
|
|
51
|
+
argBytes?: number;
|
|
44
52
|
}
|
|
45
53
|
| {
|
|
46
54
|
type: 'task:settle';
|
|
@@ -51,6 +59,8 @@ export type DevtoolsEvent =
|
|
|
51
59
|
/** dispatch→reply; absent for calls that never ran. */
|
|
52
60
|
runMs?: number;
|
|
53
61
|
error?: string;
|
|
62
|
+
/** Estimated structured-clone size of the result (`estimateCloneBytes`). */
|
|
63
|
+
resultBytes?: number;
|
|
54
64
|
}
|
|
55
65
|
| {
|
|
56
66
|
type: 'memory:bind';
|
|
@@ -59,6 +69,16 @@ export type DevtoolsEvent =
|
|
|
59
69
|
totalBytes: number;
|
|
60
70
|
}
|
|
61
71
|
| { type: 'memory:write'; path: string; version: number }
|
|
72
|
+
| {
|
|
73
|
+
/** A dashboard-set watchpoint matched on write (`memory.watch` command). */
|
|
74
|
+
type: 'memory:watch-hit';
|
|
75
|
+
path: string;
|
|
76
|
+
version: number;
|
|
77
|
+
/** Preview of the written value (`previewValue`). */
|
|
78
|
+
value: string;
|
|
79
|
+
/** The watch rule as set, e.g. '> 100', 'change', '== "down"'. */
|
|
80
|
+
rule: string;
|
|
81
|
+
}
|
|
62
82
|
| {
|
|
63
83
|
type: 'island:mount';
|
|
64
84
|
instance: string;
|
|
@@ -70,7 +90,20 @@ export type DevtoolsEvent =
|
|
|
70
90
|
framework?: string;
|
|
71
91
|
}
|
|
72
92
|
| { type: 'island:unmount'; instance: string }
|
|
73
|
-
| {
|
|
93
|
+
| {
|
|
94
|
+
type: 'island:event';
|
|
95
|
+
instance: string;
|
|
96
|
+
name: string;
|
|
97
|
+
/** Preview of the emitted payload (`previewValue`). */
|
|
98
|
+
payload?: string;
|
|
99
|
+
}
|
|
100
|
+
| {
|
|
101
|
+
/** The props an island currently holds: sent on mount and every updateProps. */
|
|
102
|
+
type: 'island:props';
|
|
103
|
+
instance: string;
|
|
104
|
+
/** Preview of the props object (`previewValue`); callbacks show as '[fn]'. */
|
|
105
|
+
props: string;
|
|
106
|
+
}
|
|
74
107
|
| {
|
|
75
108
|
type: 'island:task';
|
|
76
109
|
instance: string;
|
|
@@ -90,6 +123,8 @@ export type DevtoolsEvent =
|
|
|
90
123
|
count: number;
|
|
91
124
|
/** Main-thread replay time; worker-side render time rides call timings. */
|
|
92
125
|
replayMs?: number;
|
|
126
|
+
/** Estimated structured-clone size of the op batch (`estimateCloneBytes`). */
|
|
127
|
+
bytes?: number;
|
|
93
128
|
}
|
|
94
129
|
| {
|
|
95
130
|
type: 'net:fetch';
|
|
@@ -148,6 +183,56 @@ export type DevtoolsEvent =
|
|
|
148
183
|
* Worker contexts are identified by their script URL.
|
|
149
184
|
*/
|
|
150
185
|
contexts?: { bytes: number; scope?: string; url?: string }[];
|
|
186
|
+
}
|
|
187
|
+
| {
|
|
188
|
+
/**
|
|
189
|
+
* A main-thread frame that blocked rendering: Long Animation Frame
|
|
190
|
+
* entries where supported, `longtask` entries as the fallback.
|
|
191
|
+
*/
|
|
192
|
+
type: 'runtime:longframe';
|
|
193
|
+
/** Frame (or long task) duration. */
|
|
194
|
+
ms: number;
|
|
195
|
+
/** Time past the 50ms budget that blocked input/render. */
|
|
196
|
+
blockingMs: number;
|
|
197
|
+
/** LoAF script attribution, longest first (capped). */
|
|
198
|
+
scripts?: { src?: string; fn?: string; ms: number }[];
|
|
199
|
+
/**
|
|
200
|
+
* Page was hidden: frame duration is throttling, not work, so only
|
|
201
|
+
* frames with substantial script time are reported. Excluded from
|
|
202
|
+
* foreground jank metrics.
|
|
203
|
+
*/
|
|
204
|
+
hidden?: boolean;
|
|
205
|
+
}
|
|
206
|
+
| {
|
|
207
|
+
/** Main-thread frame rate, sampled once a second via rAF while devtools is on. */
|
|
208
|
+
type: 'runtime:frames';
|
|
209
|
+
fps: number;
|
|
210
|
+
/** Frames over ~2× the display interval in that second. */
|
|
211
|
+
dropped: number;
|
|
212
|
+
}
|
|
213
|
+
| {
|
|
214
|
+
/** A `src/log.ts` entry, mirrored into the stream (worker logs forward too). */
|
|
215
|
+
type: 'log';
|
|
216
|
+
level: 'trace' | 'debug' | 'info' | 'warn' | 'error';
|
|
217
|
+
scope: string;
|
|
218
|
+
message: string;
|
|
219
|
+
/** Preview of the structured data (`previewValue`). */
|
|
220
|
+
data?: string;
|
|
221
|
+
}
|
|
222
|
+
| {
|
|
223
|
+
/**
|
|
224
|
+
* Cross-thread reactivity edge/node update for the dependency graph.
|
|
225
|
+
* `id` names a reactive source or subscriber; `deps` are the ids it reads.
|
|
226
|
+
*/
|
|
227
|
+
type: 'reactive:node';
|
|
228
|
+
id: string;
|
|
229
|
+
kind: 'source' | 'derived' | 'effect' | 'bridge';
|
|
230
|
+
label?: string;
|
|
231
|
+
deps?: string[];
|
|
232
|
+
/** Thread-qualified owner, e.g. a pool id or island instance. */
|
|
233
|
+
owner?: string;
|
|
234
|
+
/** Set when the node is disposed. */
|
|
235
|
+
disposed?: boolean;
|
|
151
236
|
};
|
|
152
237
|
|
|
153
238
|
export type EmittedDevtoolsEvent = DevtoolsEvent & {
|
|
@@ -170,11 +255,54 @@ const THREAD: DevtoolsThread =
|
|
|
170
255
|
? 'worker'
|
|
171
256
|
: 'main';
|
|
172
257
|
|
|
258
|
+
/** The composed sink `emitDevtools` calls: null when nothing listens. */
|
|
173
259
|
let sink: DevtoolsSink | null = null;
|
|
260
|
+
/** The transport slot `setDevtoolsSink` owns. */
|
|
261
|
+
let primary: DevtoolsSink | null = null;
|
|
262
|
+
/** Additive listeners (`addDevtoolsSink`): exporters riding alongside the transport. */
|
|
263
|
+
const extraSinks = new Set<DevtoolsSink>();
|
|
174
264
|
|
|
175
|
-
|
|
265
|
+
const recompose = (): void => {
|
|
266
|
+
if (extraSinks.size === 0) {
|
|
267
|
+
sink = primary;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const all = primary ? [primary, ...extraSinks] : [...extraSinks];
|
|
271
|
+
// Fan-out isolates listeners: one throwing sink can't starve the others.
|
|
272
|
+
sink =
|
|
273
|
+
all.length === 1
|
|
274
|
+
? all[0]
|
|
275
|
+
: (event) => {
|
|
276
|
+
for (const s of all) {
|
|
277
|
+
try {
|
|
278
|
+
s(event);
|
|
279
|
+
} catch {
|
|
280
|
+
/* a broken listener must not break the app or its siblings */
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Install the transport sink (devtools transports hook here); null removes
|
|
288
|
+
* it. Additive sinks (`addDevtoolsSink`) are unaffected.
|
|
289
|
+
*/
|
|
176
290
|
export const setDevtoolsSink = (custom: DevtoolsSink | null): void => {
|
|
177
|
-
|
|
291
|
+
primary = custom;
|
|
292
|
+
recompose();
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Add a listener alongside the transport sink (exporters like OTLP);
|
|
297
|
+
* returns its remover. Any installed listener turns `devtoolsEnabled()` on,
|
|
298
|
+
* so pools spawned afterwards enable worker forwarding the same way.
|
|
299
|
+
*/
|
|
300
|
+
export const addDevtoolsSink = (listener: DevtoolsSink): (() => void) => {
|
|
301
|
+
extraSinks.add(listener);
|
|
302
|
+
recompose();
|
|
303
|
+
return () => {
|
|
304
|
+
if (extraSinks.delete(listener)) recompose();
|
|
305
|
+
};
|
|
178
306
|
};
|
|
179
307
|
|
|
180
308
|
/** Emit a structured event — one branch when no sink is installed. */
|
|
@@ -190,9 +318,109 @@ export const forwardDevtools = (event: EmittedDevtoolsEvent): void => {
|
|
|
190
318
|
if (sink) sink(event);
|
|
191
319
|
};
|
|
192
320
|
|
|
193
|
-
/** True when a devtools sink is installed on this thread. */
|
|
321
|
+
/** True when a devtools sink (transport or additive listener) is installed on this thread. */
|
|
194
322
|
export const devtoolsEnabled = (): boolean => sink !== null;
|
|
195
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Compact, bounded preview of any value for event payloads: JSON-ish,
|
|
326
|
+
* functions as '[fn]', typed arrays/buffers described by size, cycles cut,
|
|
327
|
+
* capped at `max` chars. Call only behind `devtoolsEnabled()`.
|
|
328
|
+
*/
|
|
329
|
+
export const previewValue = (value: unknown, max = 2048): string => {
|
|
330
|
+
const seen = new WeakSet<object>();
|
|
331
|
+
let out: string;
|
|
332
|
+
try {
|
|
333
|
+
out =
|
|
334
|
+
JSON.stringify(value, (_k, v: unknown) => {
|
|
335
|
+
if (typeof v === 'function') return '[fn]';
|
|
336
|
+
if (typeof v === 'bigint') return `${v}n`;
|
|
337
|
+
if (v instanceof ArrayBuffer) return `[ArrayBuffer ${v.byteLength}B]`;
|
|
338
|
+
if (ArrayBuffer.isView(v)) return `[${v.constructor.name} ${v.byteLength}B]`;
|
|
339
|
+
if (v instanceof Map) return { '[Map]': [...v.entries()].slice(0, 50) };
|
|
340
|
+
if (v instanceof Set) return { '[Set]': [...v].slice(0, 50) };
|
|
341
|
+
if (v && typeof v === 'object') {
|
|
342
|
+
if (seen.has(v)) return '[cycle]';
|
|
343
|
+
seen.add(v);
|
|
344
|
+
}
|
|
345
|
+
return v;
|
|
346
|
+
}) ?? String(value);
|
|
347
|
+
} catch {
|
|
348
|
+
out = String(value);
|
|
349
|
+
}
|
|
350
|
+
return out.length > max ? `${out.slice(0, max)}…` : out;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Rough structured-clone size in bytes: strings 2B/char, numbers 8B,
|
|
355
|
+
* typed arrays/buffers by byteLength, plus small per-key overhead. A cost
|
|
356
|
+
* signal for the dashboard ("is this message big?"), not an exact count.
|
|
357
|
+
* Walks at most `budget` nodes. Call only behind `devtoolsEnabled()`.
|
|
358
|
+
*/
|
|
359
|
+
export const estimateCloneBytes = (value: unknown, budget = 5000): number => {
|
|
360
|
+
let nodes = 0;
|
|
361
|
+
const seen = new WeakSet<object>();
|
|
362
|
+
const walk = (v: unknown): number => {
|
|
363
|
+
if (++nodes > budget) return 0;
|
|
364
|
+
switch (typeof v) {
|
|
365
|
+
case 'string': return 4 + v.length * 2;
|
|
366
|
+
case 'number': return 8;
|
|
367
|
+
case 'boolean': return 4;
|
|
368
|
+
case 'bigint': return 16;
|
|
369
|
+
case 'undefined': return 1;
|
|
370
|
+
case 'object': {
|
|
371
|
+
if (v === null) return 1;
|
|
372
|
+
if (v instanceof ArrayBuffer) return v.byteLength;
|
|
373
|
+
if (ArrayBuffer.isView(v)) return v.byteLength;
|
|
374
|
+
if (typeof SharedArrayBuffer !== 'undefined' && v instanceof SharedArrayBuffer) return 8;
|
|
375
|
+
if (seen.has(v)) return 4;
|
|
376
|
+
seen.add(v);
|
|
377
|
+
let n = 8;
|
|
378
|
+
if (v instanceof Map) for (const [k, x] of v) n += walk(k) + walk(x);
|
|
379
|
+
else if (v instanceof Set) for (const x of v) n += walk(x);
|
|
380
|
+
else if (Array.isArray(v)) for (const x of v) n += walk(x);
|
|
381
|
+
else for (const k of Object.keys(v)) n += 4 + k.length * 2 + walk((v as Record<string, unknown>)[k]);
|
|
382
|
+
return n;
|
|
383
|
+
}
|
|
384
|
+
default: return 0;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
return walk(value);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/* ── control: dashboard → app commands ───────────────────────────────────── */
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A command the dashboard can invoke on this app (`control` frames on the
|
|
394
|
+
* devtools transport). Args and result must be structured-cloneable and
|
|
395
|
+
* JSON-safe (the WebSocket path stringifies them).
|
|
396
|
+
*/
|
|
397
|
+
export type DevtoolsCommandHandler = (args: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
398
|
+
|
|
399
|
+
const commands = new Map<string, DevtoolsCommandHandler>();
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Register a dashboard-invocable command; returns an unregister function.
|
|
403
|
+
* Names are dotted by area: 'island.tree', 'worker.kill', 'memory.read'.
|
|
404
|
+
* A later registration under the same name replaces the earlier one.
|
|
405
|
+
*/
|
|
406
|
+
export const registerDevtoolsCommand = (name: string, handler: DevtoolsCommandHandler): (() => void) => {
|
|
407
|
+
commands.set(name, handler);
|
|
408
|
+
return () => {
|
|
409
|
+
if (commands.get(name) === handler) commands.delete(name);
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** Names of the registered commands — the dashboard enables controls from this. */
|
|
414
|
+
export const listDevtoolsCommands = (): string[] => [...commands.keys()].sort();
|
|
415
|
+
|
|
416
|
+
/** Run a registered command (transports call this for inbound `control` frames). */
|
|
417
|
+
export const runDevtoolsCommand = async (name: string, args: Record<string, unknown> = {}): Promise<unknown> => {
|
|
418
|
+
if (name === 'devtools.commands') return listDevtoolsCommands();
|
|
419
|
+
const handler = commands.get(name);
|
|
420
|
+
if (!handler) throw new Error(`unknown devtools command '${name}'`);
|
|
421
|
+
return handler(args);
|
|
422
|
+
};
|
|
423
|
+
|
|
196
424
|
let workerForwarding = false;
|
|
197
425
|
|
|
198
426
|
/**
|
|
@@ -486,5 +714,22 @@ export const installMemoryProbe = (scope: object, intervalMs = 2500): void => {
|
|
|
486
714
|
};
|
|
487
715
|
|
|
488
716
|
let seq = 0;
|
|
489
|
-
|
|
490
|
-
|
|
717
|
+
const named = new Map<string, number>();
|
|
718
|
+
/**
|
|
719
|
+
* Process-unique ids for runners. Unnamed: 'pool-1', 'worker-2', … Named:
|
|
720
|
+
* the slugged name plus a kind suffix, '-p' for a pool and '-w' for a
|
|
721
|
+
* dedicated worker ('regions-p', 'ops-inline-w'); a repeated name gets a
|
|
722
|
+
* counter ('regions-w', 'regions-w2'). Slugs never contain '#', '~' or
|
|
723
|
+
* '|', which the dashboard uses to qualify forwarded ids.
|
|
724
|
+
*/
|
|
725
|
+
export const nextDevtoolsId = (prefix: string, name?: string): string => {
|
|
726
|
+
const slug = name
|
|
727
|
+
?.toLowerCase()
|
|
728
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
729
|
+
.replace(/^-+|-+$/g, '');
|
|
730
|
+
if (!slug) return `${prefix}-${++seq}`;
|
|
731
|
+
const base = `${slug}-${prefix === 'pool' ? 'p' : prefix === 'worker' ? 'w' : prefix}`;
|
|
732
|
+
const n = (named.get(base) ?? 0) + 1;
|
|
733
|
+
named.set(base, n);
|
|
734
|
+
return n === 1 ? base : `${base}${n}`;
|
|
735
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -62,13 +62,18 @@ export {
|
|
|
62
62
|
TaskTimeoutError,
|
|
63
63
|
WorkerCrashedError,
|
|
64
64
|
} from './pool/errors';
|
|
65
|
-
export {
|
|
65
|
+
export { DedicatedWorker } from './pool/dedicatedWorker';
|
|
66
|
+
export type { DedicatedWorkerConfig } from './pool/dedicatedWorker';
|
|
67
|
+
export { connectWorker, resolveWorkerCount, workerClient } from './pool/workerClient';
|
|
66
68
|
export type {
|
|
67
69
|
ClientMethods,
|
|
68
70
|
ConnectWorkerConfig,
|
|
69
71
|
WorkerClient,
|
|
70
72
|
WorkerMethods,
|
|
73
|
+
WorkerRunner,
|
|
71
74
|
} from './pool/workerClient';
|
|
75
|
+
export { connectSubWorker } from './pool/subWorkerClient';
|
|
76
|
+
export type { ConnectSubWorkerConfig } from './pool/subWorkerClient';
|
|
72
77
|
|
|
73
78
|
/* ── SharedWorker — one worker (and one buffer) across tabs/iframes ──────── */
|
|
74
79
|
|
|
@@ -115,13 +120,20 @@ export type { AsyncTask, TaskSnapshot } from './task';
|
|
|
115
120
|
export { getLogLevel, log, scoped, setLogLevel, setLogSink } from './log';
|
|
116
121
|
export type { LogEntry, LogLevel, LogSink } from './log';
|
|
117
122
|
export {
|
|
123
|
+
addDevtoolsSink,
|
|
118
124
|
devtoolsEnabled,
|
|
119
125
|
emitDevtools,
|
|
126
|
+
estimateCloneBytes,
|
|
120
127
|
installFetchProbe,
|
|
121
128
|
installMemoryProbe,
|
|
129
|
+
listDevtoolsCommands,
|
|
130
|
+
previewValue,
|
|
131
|
+
registerDevtoolsCommand,
|
|
132
|
+
runDevtoolsCommand,
|
|
122
133
|
setDevtoolsSink,
|
|
123
134
|
} from './devtools';
|
|
124
135
|
export type {
|
|
136
|
+
DevtoolsCommandHandler,
|
|
125
137
|
DevtoolsEvent,
|
|
126
138
|
DevtoolsSink,
|
|
127
139
|
DevtoolsThread,
|
package/src/log.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* setLogLevel('debug'); // or 'trace', 'warn', 'off'
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { devtoolsEnabled, emitDevtools, previewValue } from './devtools';
|
|
11
|
+
|
|
10
12
|
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'off';
|
|
11
13
|
|
|
12
14
|
const LEVELS: Record<LogLevel, number> = { trace: 0, debug: 1, info: 2, warn: 3, error: 4, off: 5 };
|
|
@@ -46,9 +48,29 @@ export const getLogLevel = (): LogLevel => minLevel;
|
|
|
46
48
|
/** Replace the console sink (e.g. collect entries for a UI panel); null restores it. */
|
|
47
49
|
export const setLogSink = (custom: LogSink | null): void => { sink = custom ?? consoleSink; };
|
|
48
50
|
|
|
51
|
+
/** Set while a log entry is being mirrored, so a transport that logs can't recurse. */
|
|
52
|
+
let teeing = false;
|
|
53
|
+
|
|
49
54
|
export function log(level: Exclude<LogLevel, 'off'>, scope: string, message: string, data?: unknown): void {
|
|
50
55
|
if (LEVELS[level] < LEVELS[minLevel]) return;
|
|
51
56
|
sink({ level, scope, message, data, thread: THREAD, at: performance.now() });
|
|
57
|
+
// Mirror into the devtools stream alongside (never instead of) the log
|
|
58
|
+
// sink. Worker-side entries ride the same ATOLL_DEVTOOLS forwarding as
|
|
59
|
+
// every other worker event once the INIT handshake enables it.
|
|
60
|
+
if (!teeing && devtoolsEnabled()) {
|
|
61
|
+
teeing = true;
|
|
62
|
+
try {
|
|
63
|
+
emitDevtools({
|
|
64
|
+
type: 'log',
|
|
65
|
+
level,
|
|
66
|
+
scope,
|
|
67
|
+
message,
|
|
68
|
+
...(data !== undefined ? { data: previewValue(data, 1024) } : {}),
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
teeing = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
52
74
|
}
|
|
53
75
|
|
|
54
76
|
/** A logger bound to a subsystem scope: `poolLog.info('spawned')`. */
|
package/src/observable.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Connector, PathConnector, SharedMemory, SharedSpec, SpecPath } from './contract/sharedMemory';
|
|
2
|
-
import {
|
|
2
|
+
import { watchNodes, type SliceOptions } from './reactive';
|
|
3
|
+
import { relabelNode } from './reactiveGraph';
|
|
3
4
|
import { scoped } from './log';
|
|
4
5
|
|
|
5
6
|
const obsLog = scoped('observe');
|
|
@@ -57,7 +58,10 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
57
58
|
let pendingBind: (() => void) | null = null;
|
|
58
59
|
let rebindUn: (() => void) | null = null;
|
|
59
60
|
let watched: Connector<T> | null = null;
|
|
61
|
+
/** Devtools graph id of the watch's effect node — relabelled with the subscriber count. */
|
|
62
|
+
let effectNode: string | null = null;
|
|
60
63
|
const subscribers = new Set<(value: Out | undefined) => void>();
|
|
64
|
+
const relabel = () => relabelNode(effectNode, `observe(${String(key)}) · ${subscribers.size} sub${subscribers.size === 1 ? '' : 's'}`);
|
|
61
65
|
|
|
62
66
|
const activate = () => {
|
|
63
67
|
if (unwatch || pendingBind) return;
|
|
@@ -67,12 +71,14 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
67
71
|
} catch {
|
|
68
72
|
// Not bound on this thread yet — activate when it is.
|
|
69
73
|
pendingBind = memory.onBound(() => {
|
|
74
|
+
const un = pendingBind;
|
|
70
75
|
pendingBind = null;
|
|
76
|
+
un?.();
|
|
71
77
|
activate();
|
|
72
78
|
});
|
|
73
79
|
return;
|
|
74
80
|
}
|
|
75
|
-
|
|
81
|
+
const w = watchNodes(
|
|
76
82
|
connector,
|
|
77
83
|
selectFn as (value: T) => Sel,
|
|
78
84
|
(value) => {
|
|
@@ -83,8 +89,12 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
83
89
|
for (const cb of subscribers) cb(value);
|
|
84
90
|
}
|
|
85
91
|
},
|
|
86
|
-
options
|
|
92
|
+
options,
|
|
93
|
+
{ sliced: select !== undefined, label: 'observe' }
|
|
87
94
|
);
|
|
95
|
+
unwatch = w.stop;
|
|
96
|
+
effectNode = w.effect;
|
|
97
|
+
relabel();
|
|
88
98
|
watched = connector;
|
|
89
99
|
// bind() can REBIND this contract to a different buffer — in-process
|
|
90
100
|
// harnesses run every worker's bindSharedMemories in one module graph,
|
|
@@ -111,6 +121,7 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
111
121
|
const deactivate = () => {
|
|
112
122
|
unwatch?.();
|
|
113
123
|
unwatch = null;
|
|
124
|
+
effectNode = null;
|
|
114
125
|
watched = null;
|
|
115
126
|
pendingBind?.();
|
|
116
127
|
pendingBind = null;
|
|
@@ -142,9 +153,11 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
142
153
|
subscribe(onChange) {
|
|
143
154
|
subscribers.add(onChange);
|
|
144
155
|
activate();
|
|
156
|
+
relabel();
|
|
145
157
|
return () => {
|
|
146
158
|
subscribers.delete(onChange);
|
|
147
159
|
if (subscribers.size === 0) deactivate();
|
|
160
|
+
else relabel();
|
|
148
161
|
};
|
|
149
162
|
},
|
|
150
163
|
};
|