@atolljs/core 0.1.6 → 0.1.7

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.
@@ -1,6 +1,7 @@
1
1
  import type { Prettify, Schema } from './types';
2
2
  import { msgpackrCodec } from './msgpackrCodec';
3
3
  import { memberToSpec, zodArrayInfo, zodObjectShape, zodStringBytes } from './listSchema';
4
+ import { devtoolsEnabled, emitDevtools } from '../devtools';
4
5
  import { fmtBytes, scoped } from '../log';
5
6
  import { z, fluent } from './zod';
6
7
 
@@ -101,6 +102,8 @@ export interface ConnectorContext {
101
102
  codec: Codec;
102
103
  /** Slot in the shared version counter — bump + notify on granular writes. */
103
104
  version?: { view: Int32Array; index: number };
105
+ /** The field's contract path — for devtools write events on granular commits. */
106
+ path?: string;
104
107
  }
105
108
 
106
109
  /**
@@ -670,10 +673,13 @@ function listFactory(d: FieldDescriptor, ctx: ConnectorContext, byteOffset: numb
670
673
  writeAt: writeAt as ListConnector<any>['writeAt'],
671
674
  commit: () => {
672
675
  if (ctx.version) {
673
- Atomics.add(ctx.version.view, ctx.version.index, 1);
676
+ const prev = Atomics.add(ctx.version.view, ctx.version.index, 1);
674
677
  // Wake EVERY waitAsync observer — several watchers may share this
675
678
  // field's counter; a bounded notify starves all but one.
676
679
  Atomics.notify(ctx.version.view, ctx.version.index);
680
+ if (ctx.path && devtoolsEnabled()) {
681
+ emitDevtools({ type: 'memory:write', path: ctx.path, version: prev + 1 });
682
+ }
677
683
  }
678
684
  },
679
685
  read: () => Array.from({ length: count }, (_, i) => readAt(i)),
@@ -803,7 +809,7 @@ export class SharedMemory<S extends SharedSpec = SharedSpec> {
803
809
  throw new Error(`Unknown shared memory field kind: ${descriptor.kind}`);
804
810
  }
805
811
  memLog.debug(`bind "${path}" (${descriptor.kind}, ${fmtBytes(descriptor.byteLength)}) via ${this.plugins?.[descriptor.kind] ? 'plugin' : 'built-in'} factory`);
806
- const ctx: ConnectorContext = { buffer, codec: this.codec, version: { view: versionView, index } };
812
+ const ctx: ConnectorContext = { buffer, codec: this.codec, version: { view: versionView, index }, path };
807
813
  const raw = factory(descriptor, ctx, this.offsets.get(path)!);
808
814
  this.connectors.set(path, {
809
815
  ...raw,
@@ -812,12 +818,22 @@ export class SharedMemory<S extends SharedSpec = SharedSpec> {
812
818
  // Bump the version counter and explicitly wake waitAsync observers —
813
819
  // V8 only resolves waitAsync waiters on notify, not on value changes.
814
820
  // No count bound: every watcher on this field must wake.
815
- Atomics.add(versionView, index, 1);
821
+ const prev = Atomics.add(versionView, index, 1);
816
822
  Atomics.notify(versionView, index);
823
+ if (devtoolsEnabled()) {
824
+ emitDevtools({ type: 'memory:write', path, version: prev + 1 });
825
+ }
817
826
  },
818
827
  _version: { view: versionView, index },
819
828
  });
820
829
  });
830
+ if (devtoolsEnabled()) {
831
+ emitDevtools({
832
+ type: 'memory:bind',
833
+ fields: this.entries.map((e, i) => [i, e.path]),
834
+ totalBytes: this.totalBytes,
835
+ });
836
+ }
821
837
  this.isBound = true;
822
838
  this.boundBuffer = buffer;
823
839
  for (const cb of [...this.boundListeners]) cb();
@@ -65,6 +65,8 @@ export interface WorkerPoolConfig<S extends SharedSpec = SharedSpec, T extends T
65
65
  persistence?: (memory: SharedMemory<S>) => MemoryPersistence | undefined;
66
66
  /** Worker count — a number, or 'auto' (the default) for navigator.hardwareConcurrency ?? 4. */
67
67
  poolSize?: number | 'auto';
68
+ /** Display label for devtools/dashboards — falls back to a generated id. */
69
+ name?: string;
68
70
  memory?: MemoryConfig;
69
71
  /** Max in-flight tasks per worker (default 1); excess tasks queue FIFO. */
70
72
  concurrency?: number;
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Structured instrumentation events for the devtools/dashboard tooling.
3
+ * Same zero-cost contract as `log.ts` and islands' `proxyMetrics`: when no
4
+ * sink is installed, emit() is a single branch — production pays nothing.
5
+ *
6
+ * Events are emitted on whichever thread they occur on. Worker-side events
7
+ * reach the main thread's sink by postMessage forwarding (`ATOLL_DEVTOOLS`
8
+ * on the task channel — the pool re-emits them), so one sink sees the whole
9
+ * app. See docs/plans/devtools-dashboard.md.
10
+ */
11
+
12
+ export type DevtoolsThread = 'main' | 'worker';
13
+
14
+ export type TaskSettleOutcome =
15
+ | 'ok'
16
+ | 'error'
17
+ | 'aborted'
18
+ | 'timeout'
19
+ | 'queue-full'
20
+ | 'crashed';
21
+
22
+ export type DevtoolsEvent =
23
+ | {
24
+ type: 'pool:init';
25
+ poolId: string;
26
+ /** Config `name`, when supplied — a display label, not the id. */
27
+ label?: string;
28
+ poolSize: number;
29
+ concurrency: number;
30
+ memoryBytes?: number;
31
+ }
32
+ | { type: 'pool:terminate'; poolId: string }
33
+ | { type: 'worker:spawn'; poolId: string; slot: number }
34
+ | { type: 'worker:error'; poolId: string; slot: number; message: string }
35
+ | { type: 'worker:respawn'; poolId: string; slot: number }
36
+ | { type: 'task:enqueue'; poolId: string; callId: number; taskId: string }
37
+ | {
38
+ type: 'task:dispatch';
39
+ poolId: string;
40
+ callId: number;
41
+ taskId: string;
42
+ slot: number;
43
+ waitMs: number;
44
+ }
45
+ | {
46
+ type: 'task:settle';
47
+ poolId: string;
48
+ callId: number;
49
+ taskId: string;
50
+ outcome: TaskSettleOutcome;
51
+ /** dispatch→reply; absent for calls that never ran. */
52
+ runMs?: number;
53
+ error?: string;
54
+ }
55
+ | {
56
+ type: 'memory:bind';
57
+ /** Version-counter index → field path, for pairing memory:write. */
58
+ fields: [index: number, path: string][];
59
+ totalBytes: number;
60
+ }
61
+ | { type: 'memory:write'; path: string; version: number }
62
+ | {
63
+ type: 'island:mount';
64
+ instance: string;
65
+ app: string;
66
+ pid: string;
67
+ /** The client pool driving this island — links the island to its worker. */
68
+ poolId?: string;
69
+ /** Renderer/framework tag supplied by the mounter — observability only. */
70
+ framework?: string;
71
+ }
72
+ | { type: 'island:unmount'; instance: string }
73
+ | { type: 'island:event'; instance: string; name: string }
74
+ | {
75
+ type: 'island:task';
76
+ instance: string;
77
+ /** The client call behind the round-trip — the island's "request". */
78
+ method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount';
79
+ /** Round-trip ms: call → ops returned (worker render + transport). */
80
+ ms: number;
81
+ /** Op count in the returned batch (response size in ops). */
82
+ ops?: number;
83
+ error?: string;
84
+ }
85
+ | {
86
+ type: 'island:ops';
87
+ instance: string;
88
+ /** The client call that produced the batch. */
89
+ via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush' | 'unmount';
90
+ count: number;
91
+ /** Main-thread replay time; worker-side render time rides call timings. */
92
+ replayMs?: number;
93
+ }
94
+ | {
95
+ type: 'net:fetch';
96
+ /** Per-context sequence — correlates the net:fetch-detail follow-up. */
97
+ id: number;
98
+ url: string;
99
+ method: string;
100
+ /** HTTP status — absent when the request failed before a response. */
101
+ status?: number;
102
+ /** Time from dispatch to response headers (error: to failure). */
103
+ ms: number;
104
+ /** Response Content-Length when advertised. */
105
+ bytes?: number;
106
+ error?: string;
107
+ reqHeaders?: [string, string][];
108
+ /** Capped preview; non-text bodies are described, not dumped. */
109
+ reqBody?: string;
110
+ }
111
+ | {
112
+ /**
113
+ * Detail follow-up to net:fetch (same id) — emitted after the response
114
+ * settles so it can carry headers, a capped body preview, and the
115
+ * Resource Timing stage breakdown once the entry exists.
116
+ */
117
+ type: 'net:fetch-detail';
118
+ id: number;
119
+ /** Present when the body was only readable async (Request objects). */
120
+ reqBody?: string;
121
+ resHeaders?: [string, string][];
122
+ resBody?: string;
123
+ /**
124
+ * Stage ms from PerformanceResourceTiming: queue (stalled → fetch
125
+ * start), dns, tcp, tls, wait (request → first byte), download.
126
+ * Absent for cross-origin responses without Timing-Allow-Origin.
127
+ */
128
+ timing?: { queue: number; dns: number; tcp: number; tls: number; wait: number; download: number };
129
+ transferSize?: number;
130
+ encodedSize?: number;
131
+ decodedSize?: number;
132
+ }
133
+ | {
134
+ type: 'runtime:memory';
135
+ /**
136
+ * measureUserAgentSpecificMemory: cluster total.
137
+ * performance.memory / process.memoryUsage fallback: this context's
138
+ * usedJSHeapSize / heapUsed.
139
+ */
140
+ heapBytes: number;
141
+ heapLimitBytes?: number;
142
+ /** Resident set size — present on the Node memoryUsage() path. */
143
+ rssBytes?: number;
144
+ /**
145
+ * Per-execution-context attribution from
146
+ * `performance.measureUserAgentSpecificMemory` — emitted on the main
147
+ * thread only (workers can't self-identify among same-URL contexts).
148
+ * Worker contexts are identified by their script URL.
149
+ */
150
+ contexts?: { bytes: number; scope?: string; url?: string }[];
151
+ };
152
+
153
+ export type EmittedDevtoolsEvent = DevtoolsEvent & {
154
+ /** performance.now() on the emitting thread. */
155
+ at: number;
156
+ /** Which side of the boundary emitted the event. */
157
+ thread: DevtoolsThread;
158
+ /**
159
+ * Stamped by the pool when re-emitting a worker-forwarded event —
160
+ * attributes worker-side events (memory writes, fetches, heap samples)
161
+ * to the specific pool slot that produced them.
162
+ */
163
+ worker?: { poolId: string; slot: number };
164
+ };
165
+
166
+ export type DevtoolsSink = (event: EmittedDevtoolsEvent) => void;
167
+
168
+ const THREAD: DevtoolsThread =
169
+ typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope
170
+ ? 'worker'
171
+ : 'main';
172
+
173
+ let sink: DevtoolsSink | null = null;
174
+
175
+ /** Install the event sink (devtools transports hook here); null disables. */
176
+ export const setDevtoolsSink = (custom: DevtoolsSink | null): void => {
177
+ sink = custom;
178
+ };
179
+
180
+ /** Emit a structured event — one branch when no sink is installed. */
181
+ export const emitDevtools = (event: DevtoolsEvent): void => {
182
+ if (sink) sink({ ...event, at: performance.now(), thread: THREAD });
183
+ };
184
+
185
+ /**
186
+ * Re-emit an already-stamped event — for forwarding worker-side events that
187
+ * arrived over postMessage without re-stamping their timing/thread.
188
+ */
189
+ export const forwardDevtools = (event: EmittedDevtoolsEvent): void => {
190
+ if (sink) sink(event);
191
+ };
192
+
193
+ /** True when a devtools sink is installed on this thread. */
194
+ export const devtoolsEnabled = (): boolean => sink !== null;
195
+
196
+ let workerForwarding = false;
197
+
198
+ /**
199
+ * Worker side: route emitted events to the main thread over the task
200
+ * channel (`ATOLL_DEVTOOLS` messages — the pool re-emits them into its
201
+ * sink). Switched on by the pool's INIT handshake when the pool has a
202
+ * sink installed, so workers only forward when someone is listening.
203
+ */
204
+ export const enableWorkerDevtoolsForwarding = (): void => {
205
+ if (workerForwarding || typeof self === 'undefined') return;
206
+ workerForwarding = true;
207
+ setDevtoolsSink((event) => {
208
+ (self as unknown as Worker).postMessage({ type: 'ATOLL_DEVTOOLS', event });
209
+ });
210
+ installFetchProbe(self);
211
+ installMemoryProbe(self);
212
+ };
213
+
214
+ const PROBED = Symbol.for('atoll.devtools.fetch');
215
+
216
+ /** Body previews are capped — enough to inspect payloads, not to mirror them. */
217
+ const BODY_PREVIEW = 8 * 1024;
218
+ const TEXTISH = /text|json|javascript|xml|html|css|svg|csv|urlencoded|graphql/;
219
+
220
+ const headerList = (h?: Headers): [string, string][] | undefined =>
221
+ h ? [...h.entries()].slice(0, 64) : undefined;
222
+
223
+ /** Best-effort request body preview — only trivially-readable bodies. */
224
+ const bodyPreview = (body: BodyInit | null | undefined): string | undefined => {
225
+ if (body === null || body === undefined) return undefined;
226
+ if (typeof body === 'string') return body.slice(0, BODY_PREVIEW);
227
+ if (body instanceof URLSearchParams) return String(body).slice(0, BODY_PREVIEW);
228
+ if (body instanceof Blob) return `[${body.type || 'blob'} ${body.size}B]`;
229
+ if (body instanceof ArrayBuffer) return `[ArrayBuffer ${body.byteLength}B]`;
230
+ if (ArrayBuffer.isView(body)) return `[${body.constructor.name} ${body.byteLength}B]`;
231
+ if (typeof FormData !== 'undefined' && body instanceof FormData) return '[FormData]';
232
+ return '[stream]';
233
+ };
234
+
235
+ /** Read up to `cap` bytes of a clone — leaves the app's response untouched. */
236
+ const readPreview = async (res: Response, cap: number): Promise<string | undefined> => {
237
+ const reader = res.body?.getReader();
238
+ if (!reader) return undefined;
239
+ const chunks: Uint8Array[] = [];
240
+ let n = 0;
241
+ try {
242
+ while (n < cap) {
243
+ const { done, value } = await reader.read();
244
+ if (done || !value) break;
245
+ chunks.push(value);
246
+ n += value.byteLength;
247
+ }
248
+ } catch {
249
+ /* aborted stream — keep what we have */
250
+ }
251
+ if (!n) return undefined;
252
+ const all = new Uint8Array(n);
253
+ let off = 0;
254
+ for (const c of chunks) {
255
+ all.set(c, off);
256
+ off += c.byteLength;
257
+ }
258
+ return new TextDecoder().decode(all) + (n >= cap ? '…' : '');
259
+ };
260
+
261
+ interface FetchTiming {
262
+ queue: number;
263
+ dns: number;
264
+ tcp: number;
265
+ tls: number;
266
+ wait: number;
267
+ download: number;
268
+ }
269
+
270
+ interface TimingEntry extends FetchTiming {
271
+ transferSize: number;
272
+ encodedSize: number;
273
+ decodedSize: number;
274
+ }
275
+
276
+ /**
277
+ * Poll the resource-timing buffer for this request's entry — it only appears
278
+ * once the body fully arrives, so it can lag well past headers. Cross-origin
279
+ * entries without Timing-Allow-Origin surface with zeroed stages.
280
+ */
281
+ const findTiming = async (absUrl: string, t0: number): Promise<TimingEntry | undefined> => {
282
+ const perf = globalThis.performance as Performance | undefined;
283
+ if (!perf?.getEntriesByType) return undefined;
284
+ for (let tries = 0; tries < 12; tries++) {
285
+ let best: PerformanceResourceTiming | undefined;
286
+ for (const e of perf.getEntriesByType('resource') as PerformanceResourceTiming[]) {
287
+ if (e.name !== absUrl || e.startTime < t0 - 5) continue;
288
+ if (!best || e.startTime < best.startTime) best = e;
289
+ }
290
+ if (best) {
291
+ const seg = (a: number, b: number) => (b > a && a > 0 ? b - a : 0);
292
+ return {
293
+ queue: seg(best.fetchStart, best.domainLookupStart),
294
+ dns: seg(best.domainLookupStart, best.domainLookupEnd),
295
+ tcp:
296
+ best.secureConnectionStart > 0
297
+ ? seg(best.connectStart, best.secureConnectionStart)
298
+ : seg(best.connectStart, best.connectEnd),
299
+ tls: best.secureConnectionStart > 0 ? seg(best.secureConnectionStart, best.connectEnd) : 0,
300
+ wait: seg(best.requestStart, best.responseStart),
301
+ download: seg(best.responseStart, best.responseEnd),
302
+ transferSize: best.transferSize,
303
+ encodedSize: best.encodedBodySize,
304
+ decodedSize: best.decodedBodySize,
305
+ };
306
+ }
307
+ await new Promise((r) => setTimeout(r, 250));
308
+ }
309
+ return undefined;
310
+ };
311
+
312
+ /**
313
+ * Wrap `scope.fetch` to emit `net:fetch` (+ a `net:fetch-detail` follow-up
314
+ * with headers/body previews and stage timings) for every request — covers
315
+ * main thread (installed by connectDevtools) and workers (installed by the
316
+ * INIT handshake's forwarding flag). Idempotent; restores nothing (the
317
+ * wrapper delegates to the captured original). XHR is not covered.
318
+ */
319
+ export const installFetchProbe = (scope: { fetch?: typeof fetch }): void => {
320
+ const original = scope.fetch;
321
+ if (!original || (original as unknown as Record<symbol, true>)[PROBED]) return;
322
+ let seq = 0;
323
+ const wrapped: typeof fetch = async (input, init) => {
324
+ const t0 = performance.now();
325
+ const id = ++seq;
326
+ const isReq = typeof Request !== 'undefined' && input instanceof Request;
327
+ const url = isReq ? input.url : String(input);
328
+ const abs = (() => {
329
+ try {
330
+ return new URL(url, self.location?.href).href;
331
+ } catch {
332
+ return url;
333
+ }
334
+ })();
335
+ const method = (init?.method ?? (isReq ? input.method : 'GET')).toUpperCase();
336
+ // Clone before the app's body consumption so previews stay readable.
337
+ const reqClone = isReq && !init?.body ? input.clone() : null;
338
+ const reqHeaders = headerList(
339
+ init?.headers ? new Headers(init.headers) : isReq ? input.headers : undefined,
340
+ );
341
+ let reqBody = bodyPreview(init?.body);
342
+ try {
343
+ const res = await original.call(scope, input as RequestInfo | URL, init);
344
+ const len = res.headers.get('content-length');
345
+ emitDevtools({
346
+ type: 'net:fetch',
347
+ id,
348
+ url: url.slice(0, 300),
349
+ method,
350
+ status: res.status,
351
+ ms: performance.now() - t0,
352
+ bytes: len === null ? undefined : Number(len),
353
+ reqHeaders,
354
+ reqBody,
355
+ });
356
+ // Detail rides a follow-up event — timing entries land after the body
357
+ // does, and the caller gets `res` back without waiting on our preview.
358
+ const resClone = res.clone();
359
+ void (async () => {
360
+ reqBody ??= reqClone ? await reqClone.text().then((t) => t.slice(0, BODY_PREVIEW)).catch(() => undefined) : undefined;
361
+ const ct = res.headers.get('content-type') ?? '';
362
+ const resBody = TEXTISH.test(ct) ? await readPreview(resClone, BODY_PREVIEW).catch(() => undefined) : undefined;
363
+ const t = await findTiming(abs, t0);
364
+ emitDevtools({
365
+ type: 'net:fetch-detail',
366
+ id,
367
+ reqBody,
368
+ resHeaders: headerList(res.headers),
369
+ resBody,
370
+ timing: t,
371
+ transferSize: t?.transferSize,
372
+ encodedSize: t?.encodedSize,
373
+ decodedSize: t?.decodedSize,
374
+ });
375
+ })();
376
+ return res;
377
+ } catch (err) {
378
+ emitDevtools({
379
+ type: 'net:fetch',
380
+ id,
381
+ url: url.slice(0, 300),
382
+ method,
383
+ ms: performance.now() - t0,
384
+ error: err instanceof Error ? err.message : String(err),
385
+ reqHeaders,
386
+ reqBody,
387
+ });
388
+ throw err;
389
+ }
390
+ };
391
+ (wrapped as unknown as Record<symbol, true>)[PROBED] = true;
392
+ scope.fetch = wrapped;
393
+ };
394
+
395
+ const memProbed = new WeakSet<object>();
396
+
397
+ interface MemoryBreakdown {
398
+ bytes: number;
399
+ attribution?: { url?: string; scope?: string }[];
400
+ }
401
+ interface MeasureMemoryResult {
402
+ bytes: number;
403
+ breakdown?: MemoryBreakdown[];
404
+ }
405
+
406
+ /**
407
+ * Emit `runtime:memory` periodically — JS heap usage. On the main thread,
408
+ * prefers `performance.measureUserAgentSpecificMemory()`, which reports a
409
+ * per-execution-context breakdown (windows + workers identified by script
410
+ * URL) for the whole agent cluster; falls back to `performance.memory`,
411
+ * which also covers the worker-side install on engines that expose it
412
+ * there, then `process.memoryUsage()` on Node (heapUsed + rss — works
413
+ * inside worker_threads too). No-ops with none of the three.
414
+ */
415
+ export const installMemoryProbe = (scope: object, intervalMs = 2500): void => {
416
+ if (memProbed.has(scope)) return;
417
+ const perf = (scope as { performance?: Performance }).performance;
418
+ if (!perf) return;
419
+ const mem = (perf as {
420
+ memory?: { usedJSHeapSize: number; jsHeapSizeLimit: number };
421
+ }).memory;
422
+ const inWorker =
423
+ typeof WorkerGlobalScope !== 'undefined' &&
424
+ (globalThis as { self?: unknown }).self instanceof WorkerGlobalScope;
425
+ const measure = (
426
+ perf as { measureUserAgentSpecificMemory?: () => Promise<MeasureMemoryResult> }
427
+ ).measureUserAgentSpecificMemory?.bind(perf);
428
+ // Node fallback — process.memoryUsage() exists on the main thread and
429
+ // inside worker_threads, so per-worker heap columns fill there too.
430
+ const procMem = (
431
+ globalThis as {
432
+ process?: { memoryUsage?: () => { heapUsed: number; rss: number } };
433
+ }
434
+ ).process?.memoryUsage;
435
+ // Workers skip the breakdown path: contexts sharing a script URL can't
436
+ // be told apart, so a worker can't pick its own entry reliably.
437
+ if (!mem && (!measure || inWorker) && !procMem) return;
438
+ memProbed.add(scope);
439
+
440
+ if (measure && !inWorker) {
441
+ const timer = setInterval(() => {
442
+ measure()
443
+ .then((res) => {
444
+ const contexts = (res.breakdown ?? [])
445
+ .map((b) => ({
446
+ bytes: b.bytes,
447
+ scope: b.attribution?.[0]?.scope,
448
+ url: b.attribution?.[0]?.url,
449
+ }))
450
+ .sort((a, b) => b.bytes - a.bytes)
451
+ .slice(0, 24);
452
+ emitDevtools({
453
+ type: 'runtime:memory',
454
+ heapBytes: res.bytes,
455
+ heapLimitBytes: mem?.jsHeapSizeLimit,
456
+ contexts,
457
+ });
458
+ })
459
+ .catch(() => {
460
+ if (mem) {
461
+ emitDevtools({
462
+ type: 'runtime:memory',
463
+ heapBytes: mem.usedJSHeapSize,
464
+ heapLimitBytes: mem.jsHeapSizeLimit,
465
+ });
466
+ }
467
+ });
468
+ }, Math.max(intervalMs, 4000)); // the measurement itself may GC — keep it slower
469
+ (timer as unknown as { unref?: () => void }).unref?.();
470
+ return;
471
+ }
472
+
473
+ const timer = setInterval(() => {
474
+ if (mem) {
475
+ emitDevtools({
476
+ type: 'runtime:memory',
477
+ heapBytes: mem.usedJSHeapSize,
478
+ heapLimitBytes: mem.jsHeapSizeLimit,
479
+ });
480
+ } else if (procMem) {
481
+ const u = procMem();
482
+ emitDevtools({ type: 'runtime:memory', heapBytes: u.heapUsed, rssBytes: u.rss });
483
+ }
484
+ }, intervalMs);
485
+ (timer as unknown as { unref?: () => void }).unref?.();
486
+ };
487
+
488
+ let seq = 0;
489
+ /** Process-unique ids for pools — 'pool-1', 'pool-2', … */
490
+ export const nextDevtoolsId = (prefix: string): string => `${prefix}-${++seq}`;
package/src/index.ts CHANGED
@@ -114,3 +114,17 @@ export { defineTask, isAsyncTask, toTask } from './task';
114
114
  export type { AsyncTask, TaskSnapshot } from './task';
115
115
  export { getLogLevel, log, scoped, setLogLevel, setLogSink } from './log';
116
116
  export type { LogEntry, LogLevel, LogSink } from './log';
117
+ export {
118
+ devtoolsEnabled,
119
+ emitDevtools,
120
+ installFetchProbe,
121
+ installMemoryProbe,
122
+ setDevtoolsSink,
123
+ } from './devtools';
124
+ export type {
125
+ DevtoolsEvent,
126
+ DevtoolsSink,
127
+ DevtoolsThread,
128
+ EmittedDevtoolsEvent,
129
+ TaskSettleOutcome,
130
+ } from './devtools';
@@ -98,6 +98,8 @@ export interface ConnectWorkerConfig<S extends SharedSpec> {
98
98
  */
99
99
  worker: (() => Worker) | URL;
100
100
  poolSize?: number | 'auto';
101
+ /** Display label for devtools/dashboards — falls back to a generated id. */
102
+ name?: string;
101
103
  memory?: MemoryConfig;
102
104
  /** Max in-flight tasks per worker (default 1); excess calls queue FIFO. */
103
105
  concurrency?: number;
@@ -152,6 +154,7 @@ export function connectWorker<
152
154
  ? config.worker
153
155
  : () => new Worker(config.worker as URL, { type: 'module' }),
154
156
  poolSize: config.poolSize,
157
+ name: config.name,
155
158
  memory: config.memory,
156
159
  concurrency: config.concurrency,
157
160
  maxQueue: config.maxQueue,