@atolljs/devtools 0.1.7 → 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 (48) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/index.html +161 -2
  4. package/app/main.js +877 -127
  5. package/app/panels/actions.js +324 -0
  6. package/app/panels/audit-rules.js +728 -0
  7. package/app/panels/audits.js +438 -0
  8. package/app/panels/inspect-island.js +351 -0
  9. package/app/panels/memory-values.js +287 -0
  10. package/app/panels/performance.js +304 -0
  11. package/app/panels/reactivity.js +271 -0
  12. package/app/panels/recorder.js +344 -0
  13. package/app/panels/recording-format.js +166 -0
  14. package/app/panels/route.d.ts +44 -0
  15. package/app/panels/route.js +264 -0
  16. package/app/panels/shell.js +789 -0
  17. package/app/panels/toast.js +38 -0
  18. package/dist/bin.js +704 -5
  19. package/dist/chunk-57NGHQUL.js +344 -0
  20. package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
  21. package/dist/chunk-WIL2P7HG.js +661 -0
  22. package/dist/client.d.ts +7 -0
  23. package/dist/control.d.ts +4 -0
  24. package/dist/index.js +2 -1
  25. package/dist/node.js +2 -1
  26. package/dist/nodeWs.d.ts +1 -0
  27. package/dist/otel.d.ts +35 -0
  28. package/dist/otel.js +87 -0
  29. package/dist/otelMap.d.ts +232 -0
  30. package/dist/otelTransport.d.ts +69 -0
  31. package/dist/overlay.d.ts +16 -2
  32. package/dist/probes.d.ts +7 -0
  33. package/dist/protocol.d.ts +46 -4
  34. package/dist/server.d.ts +19 -0
  35. package/dist/server.js +61 -3
  36. package/package.json +6 -2
  37. package/src/bin.ts +27 -2
  38. package/src/broadcast.ts +6 -0
  39. package/src/client.ts +26 -2
  40. package/src/control.ts +51 -0
  41. package/src/nodeWs.ts +7 -1
  42. package/src/otel.ts +140 -0
  43. package/src/otelMap.ts +661 -0
  44. package/src/otelTransport.ts +286 -0
  45. package/src/overlay.ts +153 -27
  46. package/src/probes.ts +155 -0
  47. package/src/protocol.ts +52 -4
  48. package/src/server.ts +83 -3
package/src/probes.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Main-thread responsiveness probes, installed by the transports next to
3
+ * `installMemoryProbe`:
4
+ *
5
+ * - `runtime:longframe`: Long Animation Frame entries (blocking time plus
6
+ * the longest scripts) where the engine supports them, `longtask`
7
+ * entries otherwise. While the page is hidden, only frames with at least
8
+ * `HIDDEN_SCRIPT_MS` of script are reported, flagged `hidden: true`.
9
+ * - `runtime:frames`: a rAF sampler, one event per second with fps and the
10
+ * frames that took over ~2x the display interval.
11
+ *
12
+ * Browser main thread only: a no-op in Node and in workers. Idempotent and
13
+ * reference-counted, so two connections share one observer + one rAF loop
14
+ * and the loop stops when the last one closes.
15
+ */
16
+ import { emitDevtools } from '@atolljs/core';
17
+
18
+ const MAX_SCRIPTS = 5;
19
+ /** Long tasks past this block input; LoAF reports `blockingDuration` directly. */
20
+ const BUDGET_MS = 50;
21
+ /** While hidden, a frame is reported only with at least this much script time. */
22
+ export const HIDDEN_SCRIPT_MS = 100;
23
+ /** A gap this long means the tab was hidden/throttled, not a dropped frame. */
24
+ const STALL_MS = 1000;
25
+
26
+ interface LoafScript {
27
+ duration: number;
28
+ sourceURL?: string;
29
+ sourceFunctionName?: string;
30
+ invoker?: string;
31
+ }
32
+ interface LoafEntry extends PerformanceEntry {
33
+ blockingDuration?: number;
34
+ scripts?: LoafScript[];
35
+ }
36
+
37
+ let refs = 0;
38
+ let teardown: (() => void) | null = null;
39
+
40
+ const isBrowserMain = (): boolean =>
41
+ typeof window !== 'undefined' &&
42
+ typeof window.document !== 'undefined' &&
43
+ typeof requestAnimationFrame === 'function' &&
44
+ !(typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope);
45
+
46
+ const observeLongFrames = (): (() => void) => {
47
+ if (typeof PerformanceObserver === 'undefined') return () => {};
48
+ const supported = PerformanceObserver.supportedEntryTypes ?? [];
49
+ const type = supported.includes('long-animation-frame')
50
+ ? 'long-animation-frame'
51
+ : supported.includes('longtask')
52
+ ? 'longtask'
53
+ : null;
54
+ if (!type) return () => {};
55
+ const obs = new PerformanceObserver((list) => {
56
+ const hidden = document.visibilityState === 'hidden';
57
+ for (const e of list.getEntries() as LoafEntry[]) {
58
+ const ms = Math.round(e.duration);
59
+ const blockingMs = Math.round(e.blockingDuration ?? Math.max(0, e.duration - BUDGET_MS));
60
+ // Throttled renders (background or occluded view) stretch frames with
61
+ // no work in them, so judge by script time, not frame duration.
62
+ const scriptMs = e.scripts ? e.scripts.reduce((n, s) => n + s.duration, 0) : undefined;
63
+ if (hidden) {
64
+ // longtask entries have no script attribution: their duration is the task itself.
65
+ if ((scriptMs ?? e.duration) < HIDDEN_SCRIPT_MS) continue;
66
+ } else if (e.blockingDuration === 0 && (scriptMs ?? 0) < BUDGET_MS) continue;
67
+ const scripts = e.scripts?.length
68
+ ? [...e.scripts]
69
+ .sort((a, b) => b.duration - a.duration)
70
+ .slice(0, MAX_SCRIPTS)
71
+ .map((s) => ({
72
+ src: s.sourceURL ? s.sourceURL.slice(0, 300) : undefined,
73
+ fn: s.sourceFunctionName || s.invoker || undefined,
74
+ ms: Math.round(s.duration),
75
+ }))
76
+ : undefined;
77
+ emitDevtools({
78
+ type: 'runtime:longframe', ms, blockingMs,
79
+ ...(scripts ? { scripts } : {}),
80
+ ...(hidden ? { hidden: true } : {}),
81
+ });
82
+ }
83
+ });
84
+ try {
85
+ obs.observe({ type, buffered: true });
86
+ } catch {
87
+ return () => {};
88
+ }
89
+ return () => obs.disconnect();
90
+ };
91
+
92
+ const sampleFrames = (): (() => void) => {
93
+ let raf = 0;
94
+ let last = 0;
95
+ let windowStart = 0;
96
+ let frames = 0;
97
+ let dropped = 0;
98
+ /** Display interval estimate: the shortest frame seen (≥4ms), 60Hz until known. */
99
+ let interval = 1000 / 60;
100
+ let shortest = Infinity;
101
+ const tick = (now: number) => {
102
+ raf = requestAnimationFrame(tick);
103
+ if (!last) {
104
+ last = windowStart = now;
105
+ return;
106
+ }
107
+ const dt = now - last;
108
+ last = now;
109
+ if (dt > STALL_MS) {
110
+ // hidden tab / debugger pause: restart the window, report nothing
111
+ windowStart = now;
112
+ frames = dropped = 0;
113
+ return;
114
+ }
115
+ frames++;
116
+ if (dt >= 4 && dt < shortest) shortest = dt;
117
+ if (dt > interval * 2) dropped++;
118
+ const elapsed = now - windowStart;
119
+ if (elapsed >= 1000) {
120
+ emitDevtools({ type: 'runtime:frames', fps: Math.round((frames * 1000) / elapsed), dropped });
121
+ if (shortest !== Infinity) interval = shortest;
122
+ shortest = Infinity;
123
+ windowStart = now;
124
+ frames = dropped = 0;
125
+ }
126
+ };
127
+ raf = requestAnimationFrame(tick);
128
+ return () => cancelAnimationFrame(raf);
129
+ };
130
+
131
+ /**
132
+ * Start the long-frame observer and the frame sampler; returns a release
133
+ * function (the probes stop when every installer has released).
134
+ */
135
+ export function installJankProbe(): () => void {
136
+ if (!isBrowserMain()) return () => {};
137
+ refs++;
138
+ if (!teardown) {
139
+ const stopObs = observeLongFrames();
140
+ const stopRaf = sampleFrames();
141
+ teardown = () => {
142
+ stopObs();
143
+ stopRaf();
144
+ };
145
+ }
146
+ let released = false;
147
+ return () => {
148
+ if (released) return;
149
+ released = true;
150
+ if (--refs === 0 && teardown) {
151
+ teardown();
152
+ teardown = null;
153
+ }
154
+ };
155
+ }
package/src/protocol.ts CHANGED
@@ -13,6 +13,11 @@ export interface SessionInfo {
13
13
  id: string;
14
14
  /** Human label — config `name`, app title, process argv, etc. */
15
15
  name?: string;
16
+ /**
17
+ * The shell's own framework ('react', 'vue', 'svelte', 'solid', 'angular',
18
+ * 'vanilla', …): drawn as the main-thread hub's mark on the app map.
19
+ */
20
+ framework?: string;
16
21
  runtime: 'browser' | 'node';
17
22
  /** Browser location.href / process title — whatever helps identify it. */
18
23
  hint?: string;
@@ -26,6 +31,18 @@ export interface SessionInfo {
26
31
  closedAt?: number;
27
32
  /** Viewer-pinned closed sessions are exempt from the retention sweep. */
28
33
  pinned?: boolean;
34
+ /** Runtime facts captured at connect, for the dashboard's audits. */
35
+ env?: SessionEnv;
36
+ }
37
+
38
+ export interface SessionEnv {
39
+ /** `crossOriginIsolated` — false means no SharedArrayBuffer doorbell (islands poll). */
40
+ crossOriginIsolated?: boolean;
41
+ sharedArrayBuffer?: boolean;
42
+ hardwareConcurrency?: number;
43
+ userAgent?: string;
44
+ /** Node `process.version` on the Node path. */
45
+ node?: string;
29
46
  }
30
47
 
31
48
  export interface HelloMessage {
@@ -38,20 +55,48 @@ export interface BatchMessage {
38
55
  events: EmittedDevtoolsEvent[];
39
56
  }
40
57
 
58
+ /**
59
+ * Dashboard → app command (`runDevtoolsCommand` on the app side). `id` is
60
+ * viewer-generated and echoed on the result.
61
+ */
62
+ export interface ControlRequest {
63
+ type: 'control';
64
+ sessionId: string;
65
+ id: string;
66
+ cmd: string;
67
+ args?: Record<string, unknown>;
68
+ }
69
+
70
+ /** App → dashboard reply to a ControlRequest. */
71
+ export interface ControlResult {
72
+ type: 'control-result';
73
+ sessionId: string;
74
+ id: string;
75
+ ok: boolean;
76
+ result?: unknown;
77
+ error?: string;
78
+ }
79
+
41
80
  /** What an instrumented app sends on /events. */
42
- export type ClientMessage = HelloMessage | BatchMessage;
81
+ export type ClientMessage = HelloMessage | BatchMessage | ControlResult;
82
+
83
+ /** What the server sends an instrumented app on /events. */
84
+ export type AppMessage = ControlRequest;
43
85
 
44
86
  /** What a dashboard viewer receives on /view. */
45
87
  export type ViewerMessage =
46
88
  | { type: 'sessions'; sessions: SessionInfo[] }
47
- | { type: 'batch'; session: SessionInfo; events: EmittedDevtoolsEvent[] };
89
+ | { type: 'batch'; session: SessionInfo; events: EmittedDevtoolsEvent[] }
90
+ | ControlResult;
48
91
 
49
92
  /** What a dashboard viewer sends on /view. */
50
93
  export type ViewerRequest =
51
94
  /** Drop a closed session and its replayed history. Live sessions are ignored. */
52
95
  | { type: 'dismiss'; sessionId: string }
53
96
  /** Exempt a closed session from the retention sweep (or release it). */
54
- | { type: 'pin'; sessionId: string; pinned: boolean };
97
+ | { type: 'pin'; sessionId: string; pinned: boolean }
98
+ /** Relay a command to the live app with this session id. */
99
+ | ControlRequest;
55
100
 
56
101
  /**
57
102
  * Pure-client transport: frames on a same-origin BroadcastChannel named
@@ -66,4 +111,7 @@ export type BroadcastMessage =
66
111
  /** Posted on close/pagehide so dashboards can mark the session ended. */
67
112
  | { type: 'bye'; sessionId: string }
68
113
  /** Dashboard announcing itself — apps re-hello and replay their tail. */
69
- | { type: 'view' };
114
+ | { type: 'view' }
115
+ /** Dashboard → app command; only the app whose session id matches answers. */
116
+ | ControlRequest
117
+ | ControlResult;
package/src/server.ts CHANGED
@@ -11,6 +11,28 @@ import { join, normalize, extname } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import type { ClientMessage, SessionInfo, ViewerMessage, ViewerRequest } from './protocol';
13
13
  import { acceptWebSocket, type WsConnection } from './ws';
14
+ import { OtelMapper, type OtelSignals } from './otelMap';
15
+ import {
16
+ createOtelPipeline,
17
+ createOtlpSender,
18
+ sessionResource,
19
+ type OtelPipeline,
20
+ type OtlpSenderOptions,
21
+ type ResourceAttributes,
22
+ } from './otelTransport';
23
+
24
+ /** Forward every ingested session's events to an OTLP/HTTP endpoint. */
25
+ export interface DevtoolsServerOtlpOptions extends OtlpSenderOptions {
26
+ /** Fixed `service.name` for every session (default: each session's name). */
27
+ serviceName?: string;
28
+ /** Extra resource attributes on every session. */
29
+ resource?: ResourceAttributes;
30
+ signals?: OtelSignals;
31
+ /** Default 5000ms. */
32
+ flushIntervalMs?: number;
33
+ /** Default 512. */
34
+ maxBatch?: number;
35
+ }
14
36
 
15
37
  export interface DevtoolsServerOptions {
16
38
  /** Default 4780. 0 picks an ephemeral port (tests). */
@@ -25,6 +47,11 @@ export interface DevtoolsServerOptions {
25
47
  closedTtlMs?: number;
26
48
  /** How often the retention sweep runs (default 60s — tests lower it). */
27
49
  sweepMs?: number;
50
+ /**
51
+ * Also export everything ingested to OpenTelemetry (one OTLP resource per
52
+ * app session). Off by default.
53
+ */
54
+ otlp?: DevtoolsServerOtlpOptions;
28
55
  onListen?: (url: string) => void;
29
56
  }
30
57
 
@@ -77,6 +104,26 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
77
104
  const closedTtlMs = opts.closedTtlMs ?? 30 * 60 * 1000;
78
105
  /** Batches kept for replay — sessions updates aren't replayed (stale). */
79
106
  const tail: ViewerMessage[] = [];
107
+ /** In-flight control requests: request id → the viewer awaiting the result. */
108
+ const pendingControl = new Map<string, WsConnection>();
109
+
110
+ /** OTLP forwarding: one mapper (resource) per session id. */
111
+ let otlp: OtelPipeline | null = null;
112
+ const otlpSessions = new Map<string, SessionInfo>();
113
+ if (opts.otlp) {
114
+ const o = opts.otlp;
115
+ otlp = createOtelPipeline({
116
+ sender: createOtlpSender(o),
117
+ signals: o.signals,
118
+ flushIntervalMs: o.flushIntervalMs,
119
+ maxBatch: o.maxBatch,
120
+ newMapper: (id) =>
121
+ new OtelMapper({
122
+ resource: sessionResource(otlpSessions.get(id) ?? { id, runtime: 'browser' }, o.serviceName, o.resource),
123
+ signals: o.signals,
124
+ }),
125
+ });
126
+ }
80
127
 
81
128
  const sessionList = (): SessionInfo[] => [...sessions.values(), ...closedSessions];
82
129
 
@@ -121,7 +168,10 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
121
168
 
122
169
  if (url.pathname === '/view') {
123
170
  viewers.add(conn);
124
- conn.onClose = () => viewers.delete(conn);
171
+ conn.onClose = () => {
172
+ viewers.delete(conn);
173
+ for (const [id, v] of pendingControl) if (v === conn) pendingControl.delete(id);
174
+ };
125
175
  conn.onMessage = (text) => {
126
176
  let msg: ViewerRequest;
127
177
  try {
@@ -139,6 +189,17 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
139
189
  if (!s || s.pinned === msg.pinned) return;
140
190
  s.pinned = msg.pinned;
141
191
  broadcast({ type: 'sessions', sessions: sessionList() });
192
+ } else if (msg.type === 'control') {
193
+ const app = [...sessions].find(([, s]) => s.id === msg.sessionId)?.[0];
194
+ if (!app) {
195
+ conn.send(JSON.stringify({
196
+ type: 'control-result', sessionId: msg.sessionId, id: msg.id, ok: false,
197
+ error: 'session is not live',
198
+ } satisfies ViewerMessage));
199
+ return;
200
+ }
201
+ pendingControl.set(msg.id, conn);
202
+ app.send(JSON.stringify(msg));
142
203
  }
143
204
  };
144
205
  conn.send(JSON.stringify({ type: 'sessions', sessions: sessionList() }));
@@ -162,11 +223,27 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
162
223
  return;
163
224
  }
164
225
  if (msg.type === 'batch' && greeted) {
165
- broadcast({ type: 'batch', session: sessions.get(conn)!, events: msg.events });
226
+ const session = sessions.get(conn)!;
227
+ broadcast({ type: 'batch', session, events: msg.events });
228
+ if (otlp && Array.isArray(msg.events)) {
229
+ otlpSessions.set(session.id, session);
230
+ // Remote clocks are unknown: the mapper rebases on receive time.
231
+ const recv = Date.now();
232
+ for (const ev of msg.events) otlp.ingest(ev, recv, session.id);
233
+ }
234
+ }
235
+ if (msg.type === 'control-result') {
236
+ const viewer = pendingControl.get(msg.id);
237
+ pendingControl.delete(msg.id);
238
+ if (viewer && viewers.has(viewer)) viewer.send(JSON.stringify(msg));
166
239
  }
167
240
  };
168
241
  conn.onClose = () => {
169
242
  const info = sessions.get(conn);
243
+ if (info && otlp) {
244
+ otlp.release(info.id);
245
+ otlpSessions.delete(info.id);
246
+ }
170
247
  if (sessions.delete(conn) && info) {
171
248
  info.closed = true;
172
249
  info.closedAt = Date.now();
@@ -210,7 +287,10 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
210
287
  clearInterval(sweep);
211
288
  for (const v of viewers) v.close();
212
289
  for (const c of sessions.keys()) c.close();
213
- server.close(() => done());
290
+ server.close(() => {
291
+ if (otlp) void otlp.close().then(() => done());
292
+ else done();
293
+ });
214
294
  }),
215
295
  });
216
296
  });