@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,735 @@
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
+ /** Per-worker in-flight cap; 0 for a dedicated worker (no queue, uncapped). */
30
+ concurrency: number;
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;
37
+ }
38
+ | { type: 'pool:terminate'; poolId: string }
39
+ | { type: 'worker:spawn'; poolId: string; slot: number }
40
+ | { type: 'worker:error'; poolId: string; slot: number; message: string }
41
+ | { type: 'worker:respawn'; poolId: string; slot: number }
42
+ | { type: 'task:enqueue'; poolId: string; callId: number; taskId: string }
43
+ | {
44
+ type: 'task:dispatch';
45
+ poolId: string;
46
+ callId: number;
47
+ taskId: string;
48
+ slot: number;
49
+ waitMs: number;
50
+ /** Estimated structured-clone size of the task args (`estimateCloneBytes`). */
51
+ argBytes?: number;
52
+ }
53
+ | {
54
+ type: 'task:settle';
55
+ poolId: string;
56
+ callId: number;
57
+ taskId: string;
58
+ outcome: TaskSettleOutcome;
59
+ /** dispatch→reply; absent for calls that never ran. */
60
+ runMs?: number;
61
+ error?: string;
62
+ /** Estimated structured-clone size of the result (`estimateCloneBytes`). */
63
+ resultBytes?: number;
64
+ }
65
+ | {
66
+ type: 'memory:bind';
67
+ /** Version-counter index → field path, for pairing memory:write. */
68
+ fields: [index: number, path: string][];
69
+ totalBytes: number;
70
+ }
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
+ }
82
+ | {
83
+ type: 'island:mount';
84
+ instance: string;
85
+ app: string;
86
+ pid: string;
87
+ /** The client pool driving this island — links the island to its worker. */
88
+ poolId?: string;
89
+ /** Renderer/framework tag supplied by the mounter — observability only. */
90
+ framework?: string;
91
+ }
92
+ | { type: 'island:unmount'; instance: string }
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
+ }
107
+ | {
108
+ type: 'island:task';
109
+ instance: string;
110
+ /** The client call behind the round-trip — the island's "request". */
111
+ method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount';
112
+ /** Round-trip ms: call → ops returned (worker render + transport). */
113
+ ms: number;
114
+ /** Op count in the returned batch (response size in ops). */
115
+ ops?: number;
116
+ error?: string;
117
+ }
118
+ | {
119
+ type: 'island:ops';
120
+ instance: string;
121
+ /** The client call that produced the batch. */
122
+ via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush' | 'unmount';
123
+ count: number;
124
+ /** Main-thread replay time; worker-side render time rides call timings. */
125
+ replayMs?: number;
126
+ /** Estimated structured-clone size of the op batch (`estimateCloneBytes`). */
127
+ bytes?: number;
128
+ }
129
+ | {
130
+ type: 'net:fetch';
131
+ /** Per-context sequence — correlates the net:fetch-detail follow-up. */
132
+ id: number;
133
+ url: string;
134
+ method: string;
135
+ /** HTTP status — absent when the request failed before a response. */
136
+ status?: number;
137
+ /** Time from dispatch to response headers (error: to failure). */
138
+ ms: number;
139
+ /** Response Content-Length when advertised. */
140
+ bytes?: number;
141
+ error?: string;
142
+ reqHeaders?: [string, string][];
143
+ /** Capped preview; non-text bodies are described, not dumped. */
144
+ reqBody?: string;
145
+ }
146
+ | {
147
+ /**
148
+ * Detail follow-up to net:fetch (same id) — emitted after the response
149
+ * settles so it can carry headers, a capped body preview, and the
150
+ * Resource Timing stage breakdown once the entry exists.
151
+ */
152
+ type: 'net:fetch-detail';
153
+ id: number;
154
+ /** Present when the body was only readable async (Request objects). */
155
+ reqBody?: string;
156
+ resHeaders?: [string, string][];
157
+ resBody?: string;
158
+ /**
159
+ * Stage ms from PerformanceResourceTiming: queue (stalled → fetch
160
+ * start), dns, tcp, tls, wait (request → first byte), download.
161
+ * Absent for cross-origin responses without Timing-Allow-Origin.
162
+ */
163
+ timing?: { queue: number; dns: number; tcp: number; tls: number; wait: number; download: number };
164
+ transferSize?: number;
165
+ encodedSize?: number;
166
+ decodedSize?: number;
167
+ }
168
+ | {
169
+ type: 'runtime:memory';
170
+ /**
171
+ * measureUserAgentSpecificMemory: cluster total.
172
+ * performance.memory / process.memoryUsage fallback: this context's
173
+ * usedJSHeapSize / heapUsed.
174
+ */
175
+ heapBytes: number;
176
+ heapLimitBytes?: number;
177
+ /** Resident set size — present on the Node memoryUsage() path. */
178
+ rssBytes?: number;
179
+ /**
180
+ * Per-execution-context attribution from
181
+ * `performance.measureUserAgentSpecificMemory` — emitted on the main
182
+ * thread only (workers can't self-identify among same-URL contexts).
183
+ * Worker contexts are identified by their script URL.
184
+ */
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;
236
+ };
237
+
238
+ export type EmittedDevtoolsEvent = DevtoolsEvent & {
239
+ /** performance.now() on the emitting thread. */
240
+ at: number;
241
+ /** Which side of the boundary emitted the event. */
242
+ thread: DevtoolsThread;
243
+ /**
244
+ * Stamped by the pool when re-emitting a worker-forwarded event —
245
+ * attributes worker-side events (memory writes, fetches, heap samples)
246
+ * to the specific pool slot that produced them.
247
+ */
248
+ worker?: { poolId: string; slot: number };
249
+ };
250
+
251
+ export type DevtoolsSink = (event: EmittedDevtoolsEvent) => void;
252
+
253
+ const THREAD: DevtoolsThread =
254
+ typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope
255
+ ? 'worker'
256
+ : 'main';
257
+
258
+ /** The composed sink `emitDevtools` calls: null when nothing listens. */
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>();
264
+
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
+ */
290
+ export const setDevtoolsSink = (custom: DevtoolsSink | null): void => {
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
+ };
306
+ };
307
+
308
+ /** Emit a structured event — one branch when no sink is installed. */
309
+ export const emitDevtools = (event: DevtoolsEvent): void => {
310
+ if (sink) sink({ ...event, at: performance.now(), thread: THREAD });
311
+ };
312
+
313
+ /**
314
+ * Re-emit an already-stamped event — for forwarding worker-side events that
315
+ * arrived over postMessage without re-stamping their timing/thread.
316
+ */
317
+ export const forwardDevtools = (event: EmittedDevtoolsEvent): void => {
318
+ if (sink) sink(event);
319
+ };
320
+
321
+ /** True when a devtools sink (transport or additive listener) is installed on this thread. */
322
+ export const devtoolsEnabled = (): boolean => sink !== null;
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
+
424
+ let workerForwarding = false;
425
+
426
+ /**
427
+ * Worker side: route emitted events to the main thread over the task
428
+ * channel (`ATOLL_DEVTOOLS` messages — the pool re-emits them into its
429
+ * sink). Switched on by the pool's INIT handshake when the pool has a
430
+ * sink installed, so workers only forward when someone is listening.
431
+ */
432
+ export const enableWorkerDevtoolsForwarding = (): void => {
433
+ if (workerForwarding || typeof self === 'undefined') return;
434
+ workerForwarding = true;
435
+ setDevtoolsSink((event) => {
436
+ (self as unknown as Worker).postMessage({ type: 'ATOLL_DEVTOOLS', event });
437
+ });
438
+ installFetchProbe(self);
439
+ installMemoryProbe(self);
440
+ };
441
+
442
+ const PROBED = Symbol.for('atoll.devtools.fetch');
443
+
444
+ /** Body previews are capped — enough to inspect payloads, not to mirror them. */
445
+ const BODY_PREVIEW = 8 * 1024;
446
+ const TEXTISH = /text|json|javascript|xml|html|css|svg|csv|urlencoded|graphql/;
447
+
448
+ const headerList = (h?: Headers): [string, string][] | undefined =>
449
+ h ? [...h.entries()].slice(0, 64) : undefined;
450
+
451
+ /** Best-effort request body preview — only trivially-readable bodies. */
452
+ const bodyPreview = (body: BodyInit | null | undefined): string | undefined => {
453
+ if (body === null || body === undefined) return undefined;
454
+ if (typeof body === 'string') return body.slice(0, BODY_PREVIEW);
455
+ if (body instanceof URLSearchParams) return String(body).slice(0, BODY_PREVIEW);
456
+ if (body instanceof Blob) return `[${body.type || 'blob'} ${body.size}B]`;
457
+ if (body instanceof ArrayBuffer) return `[ArrayBuffer ${body.byteLength}B]`;
458
+ if (ArrayBuffer.isView(body)) return `[${body.constructor.name} ${body.byteLength}B]`;
459
+ if (typeof FormData !== 'undefined' && body instanceof FormData) return '[FormData]';
460
+ return '[stream]';
461
+ };
462
+
463
+ /** Read up to `cap` bytes of a clone — leaves the app's response untouched. */
464
+ const readPreview = async (res: Response, cap: number): Promise<string | undefined> => {
465
+ const reader = res.body?.getReader();
466
+ if (!reader) return undefined;
467
+ const chunks: Uint8Array[] = [];
468
+ let n = 0;
469
+ try {
470
+ while (n < cap) {
471
+ const { done, value } = await reader.read();
472
+ if (done || !value) break;
473
+ chunks.push(value);
474
+ n += value.byteLength;
475
+ }
476
+ } catch {
477
+ /* aborted stream — keep what we have */
478
+ }
479
+ if (!n) return undefined;
480
+ const all = new Uint8Array(n);
481
+ let off = 0;
482
+ for (const c of chunks) {
483
+ all.set(c, off);
484
+ off += c.byteLength;
485
+ }
486
+ return new TextDecoder().decode(all) + (n >= cap ? '…' : '');
487
+ };
488
+
489
+ interface FetchTiming {
490
+ queue: number;
491
+ dns: number;
492
+ tcp: number;
493
+ tls: number;
494
+ wait: number;
495
+ download: number;
496
+ }
497
+
498
+ interface TimingEntry extends FetchTiming {
499
+ transferSize: number;
500
+ encodedSize: number;
501
+ decodedSize: number;
502
+ }
503
+
504
+ /**
505
+ * Poll the resource-timing buffer for this request's entry — it only appears
506
+ * once the body fully arrives, so it can lag well past headers. Cross-origin
507
+ * entries without Timing-Allow-Origin surface with zeroed stages.
508
+ */
509
+ const findTiming = async (absUrl: string, t0: number): Promise<TimingEntry | undefined> => {
510
+ const perf = globalThis.performance as Performance | undefined;
511
+ if (!perf?.getEntriesByType) return undefined;
512
+ for (let tries = 0; tries < 12; tries++) {
513
+ let best: PerformanceResourceTiming | undefined;
514
+ for (const e of perf.getEntriesByType('resource') as PerformanceResourceTiming[]) {
515
+ if (e.name !== absUrl || e.startTime < t0 - 5) continue;
516
+ if (!best || e.startTime < best.startTime) best = e;
517
+ }
518
+ if (best) {
519
+ const seg = (a: number, b: number) => (b > a && a > 0 ? b - a : 0);
520
+ return {
521
+ queue: seg(best.fetchStart, best.domainLookupStart),
522
+ dns: seg(best.domainLookupStart, best.domainLookupEnd),
523
+ tcp:
524
+ best.secureConnectionStart > 0
525
+ ? seg(best.connectStart, best.secureConnectionStart)
526
+ : seg(best.connectStart, best.connectEnd),
527
+ tls: best.secureConnectionStart > 0 ? seg(best.secureConnectionStart, best.connectEnd) : 0,
528
+ wait: seg(best.requestStart, best.responseStart),
529
+ download: seg(best.responseStart, best.responseEnd),
530
+ transferSize: best.transferSize,
531
+ encodedSize: best.encodedBodySize,
532
+ decodedSize: best.decodedBodySize,
533
+ };
534
+ }
535
+ await new Promise((r) => setTimeout(r, 250));
536
+ }
537
+ return undefined;
538
+ };
539
+
540
+ /**
541
+ * Wrap `scope.fetch` to emit `net:fetch` (+ a `net:fetch-detail` follow-up
542
+ * with headers/body previews and stage timings) for every request — covers
543
+ * main thread (installed by connectDevtools) and workers (installed by the
544
+ * INIT handshake's forwarding flag). Idempotent; restores nothing (the
545
+ * wrapper delegates to the captured original). XHR is not covered.
546
+ */
547
+ export const installFetchProbe = (scope: { fetch?: typeof fetch }): void => {
548
+ const original = scope.fetch;
549
+ if (!original || (original as unknown as Record<symbol, true>)[PROBED]) return;
550
+ let seq = 0;
551
+ const wrapped: typeof fetch = async (input, init) => {
552
+ const t0 = performance.now();
553
+ const id = ++seq;
554
+ const isReq = typeof Request !== 'undefined' && input instanceof Request;
555
+ const url = isReq ? input.url : String(input);
556
+ const abs = (() => {
557
+ try {
558
+ return new URL(url, self.location?.href).href;
559
+ } catch {
560
+ return url;
561
+ }
562
+ })();
563
+ const method = (init?.method ?? (isReq ? input.method : 'GET')).toUpperCase();
564
+ // Clone before the app's body consumption so previews stay readable.
565
+ const reqClone = isReq && !init?.body ? input.clone() : null;
566
+ const reqHeaders = headerList(
567
+ init?.headers ? new Headers(init.headers) : isReq ? input.headers : undefined,
568
+ );
569
+ let reqBody = bodyPreview(init?.body);
570
+ try {
571
+ const res = await original.call(scope, input as RequestInfo | URL, init);
572
+ const len = res.headers.get('content-length');
573
+ emitDevtools({
574
+ type: 'net:fetch',
575
+ id,
576
+ url: url.slice(0, 300),
577
+ method,
578
+ status: res.status,
579
+ ms: performance.now() - t0,
580
+ bytes: len === null ? undefined : Number(len),
581
+ reqHeaders,
582
+ reqBody,
583
+ });
584
+ // Detail rides a follow-up event — timing entries land after the body
585
+ // does, and the caller gets `res` back without waiting on our preview.
586
+ const resClone = res.clone();
587
+ void (async () => {
588
+ reqBody ??= reqClone ? await reqClone.text().then((t) => t.slice(0, BODY_PREVIEW)).catch(() => undefined) : undefined;
589
+ const ct = res.headers.get('content-type') ?? '';
590
+ const resBody = TEXTISH.test(ct) ? await readPreview(resClone, BODY_PREVIEW).catch(() => undefined) : undefined;
591
+ const t = await findTiming(abs, t0);
592
+ emitDevtools({
593
+ type: 'net:fetch-detail',
594
+ id,
595
+ reqBody,
596
+ resHeaders: headerList(res.headers),
597
+ resBody,
598
+ timing: t,
599
+ transferSize: t?.transferSize,
600
+ encodedSize: t?.encodedSize,
601
+ decodedSize: t?.decodedSize,
602
+ });
603
+ })();
604
+ return res;
605
+ } catch (err) {
606
+ emitDevtools({
607
+ type: 'net:fetch',
608
+ id,
609
+ url: url.slice(0, 300),
610
+ method,
611
+ ms: performance.now() - t0,
612
+ error: err instanceof Error ? err.message : String(err),
613
+ reqHeaders,
614
+ reqBody,
615
+ });
616
+ throw err;
617
+ }
618
+ };
619
+ (wrapped as unknown as Record<symbol, true>)[PROBED] = true;
620
+ scope.fetch = wrapped;
621
+ };
622
+
623
+ const memProbed = new WeakSet<object>();
624
+
625
+ interface MemoryBreakdown {
626
+ bytes: number;
627
+ attribution?: { url?: string; scope?: string }[];
628
+ }
629
+ interface MeasureMemoryResult {
630
+ bytes: number;
631
+ breakdown?: MemoryBreakdown[];
632
+ }
633
+
634
+ /**
635
+ * Emit `runtime:memory` periodically — JS heap usage. On the main thread,
636
+ * prefers `performance.measureUserAgentSpecificMemory()`, which reports a
637
+ * per-execution-context breakdown (windows + workers identified by script
638
+ * URL) for the whole agent cluster; falls back to `performance.memory`,
639
+ * which also covers the worker-side install on engines that expose it
640
+ * there, then `process.memoryUsage()` on Node (heapUsed + rss — works
641
+ * inside worker_threads too). No-ops with none of the three.
642
+ */
643
+ export const installMemoryProbe = (scope: object, intervalMs = 2500): void => {
644
+ if (memProbed.has(scope)) return;
645
+ const perf = (scope as { performance?: Performance }).performance;
646
+ if (!perf) return;
647
+ const mem = (perf as {
648
+ memory?: { usedJSHeapSize: number; jsHeapSizeLimit: number };
649
+ }).memory;
650
+ const inWorker =
651
+ typeof WorkerGlobalScope !== 'undefined' &&
652
+ (globalThis as { self?: unknown }).self instanceof WorkerGlobalScope;
653
+ const measure = (
654
+ perf as { measureUserAgentSpecificMemory?: () => Promise<MeasureMemoryResult> }
655
+ ).measureUserAgentSpecificMemory?.bind(perf);
656
+ // Node fallback — process.memoryUsage() exists on the main thread and
657
+ // inside worker_threads, so per-worker heap columns fill there too.
658
+ const procMem = (
659
+ globalThis as {
660
+ process?: { memoryUsage?: () => { heapUsed: number; rss: number } };
661
+ }
662
+ ).process?.memoryUsage;
663
+ // Workers skip the breakdown path: contexts sharing a script URL can't
664
+ // be told apart, so a worker can't pick its own entry reliably.
665
+ if (!mem && (!measure || inWorker) && !procMem) return;
666
+ memProbed.add(scope);
667
+
668
+ if (measure && !inWorker) {
669
+ const timer = setInterval(() => {
670
+ measure()
671
+ .then((res) => {
672
+ const contexts = (res.breakdown ?? [])
673
+ .map((b) => ({
674
+ bytes: b.bytes,
675
+ scope: b.attribution?.[0]?.scope,
676
+ url: b.attribution?.[0]?.url,
677
+ }))
678
+ .sort((a, b) => b.bytes - a.bytes)
679
+ .slice(0, 24);
680
+ emitDevtools({
681
+ type: 'runtime:memory',
682
+ heapBytes: res.bytes,
683
+ heapLimitBytes: mem?.jsHeapSizeLimit,
684
+ contexts,
685
+ });
686
+ })
687
+ .catch(() => {
688
+ if (mem) {
689
+ emitDevtools({
690
+ type: 'runtime:memory',
691
+ heapBytes: mem.usedJSHeapSize,
692
+ heapLimitBytes: mem.jsHeapSizeLimit,
693
+ });
694
+ }
695
+ });
696
+ }, Math.max(intervalMs, 4000)); // the measurement itself may GC — keep it slower
697
+ (timer as unknown as { unref?: () => void }).unref?.();
698
+ return;
699
+ }
700
+
701
+ const timer = setInterval(() => {
702
+ if (mem) {
703
+ emitDevtools({
704
+ type: 'runtime:memory',
705
+ heapBytes: mem.usedJSHeapSize,
706
+ heapLimitBytes: mem.jsHeapSizeLimit,
707
+ });
708
+ } else if (procMem) {
709
+ const u = procMem();
710
+ emitDevtools({ type: 'runtime:memory', heapBytes: u.heapUsed, rssBytes: u.rss });
711
+ }
712
+ }, intervalMs);
713
+ (timer as unknown as { unref?: () => void }).unref?.();
714
+ };
715
+
716
+ let seq = 0;
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
+ };