cdp-toolkit 1.0.0

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.
@@ -0,0 +1,492 @@
1
+ /**
2
+ * performance.ts — performance tracing + insight analysis over raw CDP.
3
+ *
4
+ * NOTE ON THE INSIGHT ANALYZER: `performanceAnalyzeInsight` is a CDP-native
5
+ * APPROXIMATION of chrome-devtools-mcp's insight analyzer. The MCP tool runs the
6
+ * DevTools/Lighthouse "Trace Engine" insight pipeline (a large TS engine that
7
+ * models the full RAIL/Core-Web-Vitals heuristics). We do NOT vendor that
8
+ * engine (zero-runtime-deps rule). Instead we parse the raw Chrome trace event
9
+ * stream ourselves and derive the headline metrics — navigationStart, FCP, LCP,
10
+ * long tasks (>50ms), layout shifts / CLS, and total blocking time — directly
11
+ * from the trace events. Numbers will be close to but not byte-identical with
12
+ * the MCP/DevTools panel, which applies extra normalization (e.g. main-thread
13
+ * attribution, frame-scoped LCP, soft-navigation handling).
14
+ *
15
+ * NOTE ON TRACING STATE: CDP `Tracing.start`/`Tracing.end` are bound to a single
16
+ * live session — the recording lives on the WebSocket connection, and
17
+ * `Tracing.dataCollected` events stream back on that same connection. There is
18
+ * no way to "re-attach" to an in-flight trace from a fresh process, because the
19
+ * buffer is owned by the connection that started it. Cross-process persistence
20
+ * of a live recording is therefore INFEASIBLE.
21
+ *
22
+ * What we do instead:
23
+ * - `performanceStartTrace` / `performanceStopTrace` are implemented as a
24
+ * best-effort in-process pair: start opens a persistent page connection,
25
+ * parks it in a module-level registry keyed by targetId, and writes a state
26
+ * file recording which target is being traced. stop looks up the live
27
+ * connection in the registry, ends the trace, drains data, writes JSON.
28
+ * This works when start and stop run in the SAME process (e.g. the MCP
29
+ * dispatcher staying resident, or a single CLI invocation that does both).
30
+ * If stop runs in a DIFFERENT process than start, the connection is gone and
31
+ * stop throws a clear CdpError telling the caller to use `performanceTrace`.
32
+ * - `performanceTrace` is the PRIMARY, robust one-shot: it starts tracing,
33
+ * optionally reloads/navigates the page, waits `durationMs`, ends the trace,
34
+ * writes the trace JSON, and returns `{ path, bytes }`. This is the smoke
35
+ * path and the recommended entry point.
36
+ */
37
+ import type { Target, TargetSelector } from "../types.ts";
38
+ import type { CdpConnection } from "../client.ts";
39
+ import { CdpError, openPage } from "../client.ts";
40
+ import { mkdir, readFile, writeFile, stat, unlink } from "node:fs/promises";
41
+ import { join } from "node:path";
42
+
43
+ const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
44
+ const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
45
+
46
+ /** Default trace categories: timeline + user timing + loading + the disabled-by-default timeline track that carries LCP/LayoutShift/RunTask. */
47
+ const DEFAULT_CATEGORIES = [
48
+ "devtools.timeline",
49
+ "blink.user_timing",
50
+ "loading",
51
+ "disabled-by-default-devtools.timeline",
52
+ "disabled-by-default-devtools.timeline.frame",
53
+ ];
54
+
55
+ /** A single Chrome trace event (the shape we read from Tracing.dataCollected / saved JSON). */
56
+ interface TraceEvent {
57
+ cat?: string;
58
+ name?: string;
59
+ ph?: string;
60
+ ts?: number; // microseconds
61
+ dur?: number; // microseconds
62
+ pid?: number;
63
+ tid?: number;
64
+ args?: Record<string, unknown> & {
65
+ data?: Record<string, unknown> & {
66
+ score?: number;
67
+ had_recent_input?: boolean;
68
+ candidateIndex?: number;
69
+ size?: number;
70
+ navigationId?: string;
71
+ };
72
+ frame?: string;
73
+ };
74
+ }
75
+
76
+ /** A traced-page registry entry held in-process between start and stop. */
77
+ interface LiveTrace {
78
+ conn: CdpConnection;
79
+ target: Target;
80
+ events: TraceEvent[];
81
+ startedAt: number;
82
+ }
83
+
84
+ /**
85
+ * In-process registry of live traces keyed by targetId. Survives only within a
86
+ * single Node/Bun process (see header note on cross-process infeasibility).
87
+ */
88
+ const liveTraces = new Map<string, LiveTrace>();
89
+
90
+ interface TraceStateFile {
91
+ targetId: string;
92
+ url: string;
93
+ startedAt: number;
94
+ pid: number;
95
+ categories: string[];
96
+ }
97
+
98
+ function stamp(): string {
99
+ return new Date().toISOString().replace(/[:.]/g, "-");
100
+ }
101
+
102
+ function shortId(id: string): string {
103
+ return id.slice(0, 8);
104
+ }
105
+
106
+ function stateFilePath(): string {
107
+ return join(STATE_DIR, "trace-state.json");
108
+ }
109
+
110
+ /** Subscribe to Tracing.dataCollected and buffer every event into `sink`. */
111
+ function bufferTraceData(conn: CdpConnection, sink: TraceEvent[]): () => void {
112
+ return conn.on("Tracing.dataCollected", (params: Record<string, unknown>) => {
113
+ const value = (params as { value?: TraceEvent[] }).value;
114
+ if (Array.isArray(value)) sink.push(...value);
115
+ });
116
+ }
117
+
118
+ /** Begin a trace on an already-open page connection. */
119
+ async function beginTrace(conn: CdpConnection, categories: string[]): Promise<void> {
120
+ await conn.send("Tracing.start", {
121
+ traceConfig: { includedCategories: categories },
122
+ transferMode: "ReportEvents",
123
+ });
124
+ }
125
+
126
+ /**
127
+ * End a trace, drain all buffered Tracing.dataCollected, and resolve once
128
+ * Tracing.tracingComplete fires (or the timeout elapses). Returns the events.
129
+ */
130
+ async function endTrace(conn: CdpConnection, sink: TraceEvent[], timeoutMs = 30_000): Promise<TraceEvent[]> {
131
+ const complete = conn.waitFor("Tracing.tracingComplete", undefined, timeoutMs);
132
+ await conn.send("Tracing.end");
133
+ await complete;
134
+ return sink;
135
+ }
136
+
137
+ /** Write a trace event array to ARTIFACT_DIR as a DevTools-loadable JSON file. */
138
+ async function writeTrace(events: TraceEvent[], targetId: string): Promise<{ path: string; bytes: number }> {
139
+ await mkdir(ARTIFACT_DIR, { recursive: true });
140
+ const path = join(ARTIFACT_DIR, `trace-${shortId(targetId)}-${stamp()}.json`);
141
+ // DevTools accepts either a bare array or { traceEvents: [...] }; use the wrapped
142
+ // form so the file is also openable in the Performance panel.
143
+ const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
144
+ await writeFile(path, body, "utf8");
145
+ const { size } = await stat(path);
146
+ return { path, bytes: size };
147
+ }
148
+
149
+ /* ------------------------------------------------------------------ */
150
+ /* performanceStartTrace / performanceStopTrace (in-process pair) */
151
+ /* ------------------------------------------------------------------ */
152
+
153
+ export interface PerformanceStartTraceArgs {
154
+ target?: TargetSelector;
155
+ categories?: string[];
156
+ }
157
+
158
+ /**
159
+ * Start a performance trace on the target page and keep the recording
160
+ * connection alive in-process. Pairs with `performanceStopTrace` WITHIN THE
161
+ * SAME PROCESS. For a robust cross-call trace, prefer `performanceTrace`.
162
+ */
163
+ export async function performanceStartTrace(
164
+ args: PerformanceStartTraceArgs = {},
165
+ ): Promise<{ target: { id: string; url: string; title: string }; categories: string[]; note: string }> {
166
+ const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
167
+ const { conn, target } = await openPage(args.target);
168
+
169
+ if (liveTraces.has(target.id)) {
170
+ conn.close();
171
+ throw new CdpError(`a trace is already in progress for target ${shortId(target.id)}; call performanceStopTrace first`);
172
+ }
173
+
174
+ const events: TraceEvent[] = [];
175
+ bufferTraceData(conn, events);
176
+ try {
177
+ await beginTrace(conn, categories);
178
+ } catch (err) {
179
+ conn.close();
180
+ throw err;
181
+ }
182
+
183
+ liveTraces.set(target.id, { conn, target, events, startedAt: Date.now() });
184
+
185
+ await mkdir(STATE_DIR, { recursive: true });
186
+ const state: TraceStateFile = {
187
+ targetId: target.id,
188
+ url: target.url,
189
+ startedAt: Date.now(),
190
+ pid: process.pid,
191
+ categories,
192
+ };
193
+ await writeFile(stateFilePath(), JSON.stringify(state), "utf8");
194
+
195
+ return {
196
+ target: { id: target.id, url: target.url, title: target.title },
197
+ categories,
198
+ note: "Trace recording. Call performanceStopTrace in THIS process to finalize. Cross-process? use performanceTrace instead.",
199
+ };
200
+ }
201
+
202
+ export interface PerformanceStopTraceArgs {
203
+ target?: TargetSelector;
204
+ }
205
+
206
+ /**
207
+ * Stop the in-process trace started by `performanceStartTrace`, drain the
208
+ * buffered events, write the trace JSON, and clear state. Throws if no live
209
+ * trace exists in this process (e.g. start ran in a different process).
210
+ */
211
+ export async function performanceStopTrace(
212
+ args: PerformanceStopTraceArgs = {},
213
+ ): Promise<{ path: string; bytes: number; events: number; metrics: TraceMetrics }> {
214
+ // CDP tracing is browser-GLOBAL: a second Tracing.start throws "Tracing has
215
+ // already been started", so at most one trace is ever live in this process.
216
+ // There is therefore nothing to disambiguate by target — take the single live
217
+ // trace if present. (`args.target` is accepted for API symmetry with start.)
218
+ void args.target;
219
+ const entry: LiveTrace | undefined = liveTraces.size === 1 ? [...liveTraces.values()][0] : undefined;
220
+
221
+ if (!entry) {
222
+ let hint = "";
223
+ try {
224
+ const raw = await readFile(stateFilePath(), "utf8");
225
+ const state = JSON.parse(raw) as TraceStateFile;
226
+ hint =
227
+ state.pid === process.pid
228
+ ? ` (state file lists target ${shortId(state.targetId)} but its live connection is gone)`
229
+ : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
230
+ } catch {
231
+ /* no state file */
232
+ }
233
+ throw new CdpError(
234
+ `no in-process trace to stop${hint}. Use performanceTrace for a robust single-call trace.`,
235
+ );
236
+ }
237
+
238
+ const { conn, target, events } = entry;
239
+ try {
240
+ await endTrace(conn, events);
241
+ } finally {
242
+ conn.close();
243
+ liveTraces.delete(target.id);
244
+ await unlink(stateFilePath()).catch(() => {});
245
+ }
246
+
247
+ const written = await writeTrace(events, target.id);
248
+ const metrics = analyzeEvents(events);
249
+ return { ...written, events: events.length, metrics };
250
+ }
251
+
252
+ /* ------------------------------------------------------------------ */
253
+ /* performanceTrace (PRIMARY one-shot — the smoke path) */
254
+ /* ------------------------------------------------------------------ */
255
+
256
+ export interface PerformanceTraceArgs {
257
+ target?: TargetSelector;
258
+ /** how long to record after (optionally) reloading, ms. Default 3000. */
259
+ durationMs?: number;
260
+ /** reload the page after starting the trace (captures full nav timing). */
261
+ reload?: boolean;
262
+ /** navigate to this URL after starting the trace (alternative to reload). */
263
+ navigateTo?: string;
264
+ categories?: string[];
265
+ }
266
+
267
+ /**
268
+ * One-shot trace: start → (reload/navigate) → wait → stop → write JSON. Holds a
269
+ * single connection open for the whole window, so it is immune to the
270
+ * cross-process limitation of start/stop. This is the recommended entry point.
271
+ */
272
+ export async function performanceTrace(
273
+ args: PerformanceTraceArgs = {},
274
+ ): Promise<{ path: string; bytes: number; events: number; metrics: TraceMetrics; target: { id: string; url: string } }> {
275
+ const durationMs = args.durationMs ?? 3_000;
276
+ const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
277
+ const { conn, target } = await openPage(args.target);
278
+ const events: TraceEvent[] = [];
279
+ bufferTraceData(conn, events);
280
+
281
+ try {
282
+ await conn.send("Page.enable");
283
+ await beginTrace(conn, categories);
284
+
285
+ if (args.navigateTo) {
286
+ const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5_000);
287
+ await conn.send("Page.navigate", { url: args.navigateTo });
288
+ await loaded.catch(() => {});
289
+ } else if (args.reload) {
290
+ const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5_000);
291
+ await conn.send("Page.reload", { ignoreCache: false });
292
+ await loaded.catch(() => {});
293
+ }
294
+
295
+ await new Promise((r) => setTimeout(r, durationMs));
296
+ await endTrace(conn, events);
297
+ } finally {
298
+ conn.close();
299
+ }
300
+
301
+ const written = await writeTrace(events, target.id);
302
+ const metrics = analyzeEvents(events);
303
+ return { ...written, events: events.length, metrics, target: { id: target.id, url: target.url } };
304
+ }
305
+
306
+ /* ------------------------------------------------------------------ */
307
+ /* performanceAnalyzeInsight (CDP-native approximation) */
308
+ /* ------------------------------------------------------------------ */
309
+
310
+ export interface PerformanceMetrics {
311
+ /** navigationStart timestamp in trace-clock microseconds, if found. */
312
+ navigationStartUs?: number;
313
+ /** First Contentful Paint, ms after navigationStart. */
314
+ fcpMs?: number;
315
+ /** Largest Contentful Paint, ms after navigationStart. */
316
+ lcpMs?: number;
317
+ /** DOMContentLoaded, ms after navigationStart. */
318
+ domContentLoadedMs?: number;
319
+ /** load event, ms after navigationStart. */
320
+ loadMs?: number;
321
+ /** Cumulative Layout Shift score (sum of layout-shift scores w/o recent input). */
322
+ cls?: number;
323
+ /** Total Blocking Time: sum over long tasks of (dur_ms - 50), ms. */
324
+ totalBlockingTimeMs: number;
325
+ }
326
+
327
+ export interface TraceMetrics {
328
+ metrics: PerformanceMetrics;
329
+ longTasks: number;
330
+ longTasksTotalMs: number;
331
+ layoutShifts: number;
332
+ eventCount: number;
333
+ }
334
+
335
+ /**
336
+ * Parse a raw Chrome trace event array into headline metrics. Pure function so
337
+ * both the live tools and `performanceAnalyzeInsight` share one code path.
338
+ */
339
+ export function analyzeEvents(events: TraceEvent[]): TraceMetrics {
340
+ let navigationStartUs: number | undefined;
341
+ let fcpUs: number | undefined;
342
+ let lcpUs: number | undefined;
343
+ let dclUs: number | undefined;
344
+ let loadUs: number | undefined;
345
+ let cls = 0;
346
+ let layoutShifts = 0;
347
+ let longTasks = 0;
348
+ let longTasksTotalMs = 0;
349
+ let totalBlockingTimeMs = 0;
350
+
351
+ for (const e of events) {
352
+ const name = e.name;
353
+ if (!name) continue;
354
+
355
+ // navigationStart: prefer the earliest marker we see.
356
+ if (name === "navigationStart") {
357
+ if (typeof e.ts === "number" && (navigationStartUs === undefined || e.ts < navigationStartUs)) {
358
+ navigationStartUs = e.ts;
359
+ }
360
+ continue;
361
+ }
362
+
363
+ // First Contentful Paint marker.
364
+ if ((name === "firstContentfulPaint" || name === "FirstContentfulPaint") && typeof e.ts === "number") {
365
+ if (fcpUs === undefined || e.ts < fcpUs) fcpUs = e.ts;
366
+ continue;
367
+ }
368
+
369
+ // LCP candidate — the LAST candidate before user input is the real LCP.
370
+ if (name === "largestContentfulPaint::Candidate" && typeof e.ts === "number") {
371
+ // candidates are monotonic; keep the latest timestamp seen.
372
+ if (lcpUs === undefined || e.ts > lcpUs) lcpUs = e.ts;
373
+ continue;
374
+ }
375
+
376
+ if ((name === "domContentLoadedEventEnd" || name === "DOMContentLoaded") && typeof e.ts === "number") {
377
+ if (dclUs === undefined || e.ts > dclUs) dclUs = e.ts;
378
+ continue;
379
+ }
380
+
381
+ if ((name === "loadEventEnd" || name === "MarkLoad") && typeof e.ts === "number") {
382
+ if (loadUs === undefined || e.ts > loadUs) loadUs = e.ts;
383
+ continue;
384
+ }
385
+
386
+ // Layout shifts — sum scores that did NOT follow recent user input.
387
+ if (name === "LayoutShift") {
388
+ const data = e.args?.data;
389
+ const hadRecentInput = data?.had_recent_input === true;
390
+ const score = typeof data?.score === "number" ? data.score : undefined;
391
+ if (!hadRecentInput && typeof score === "number") {
392
+ cls += score;
393
+ layoutShifts += 1;
394
+ }
395
+ continue;
396
+ }
397
+
398
+ // Long tasks: complete ('X') RunTask events with dur > 50ms contribute to
399
+ // TBT (the portion of each task beyond 50ms).
400
+ if (name === "RunTask" && typeof e.dur === "number") {
401
+ const durMs = e.dur / 1000;
402
+ if (durMs > 50) {
403
+ longTasks += 1;
404
+ longTasksTotalMs += durMs;
405
+ totalBlockingTimeMs += durMs - 50;
406
+ }
407
+ continue;
408
+ }
409
+ }
410
+
411
+ const relMs = (us: number | undefined): number | undefined =>
412
+ us !== undefined && navigationStartUs !== undefined ? (us - navigationStartUs) / 1000 : undefined;
413
+
414
+ const metrics: PerformanceMetrics = {
415
+ navigationStartUs,
416
+ fcpMs: relMs(fcpUs),
417
+ lcpMs: relMs(lcpUs),
418
+ domContentLoadedMs: relMs(dclUs),
419
+ loadMs: relMs(loadUs),
420
+ cls: layoutShifts > 0 ? Number(cls.toFixed(4)) : 0,
421
+ totalBlockingTimeMs: Number(totalBlockingTimeMs.toFixed(2)),
422
+ };
423
+
424
+ return {
425
+ metrics,
426
+ longTasks,
427
+ longTasksTotalMs: Number(longTasksTotalMs.toFixed(2)),
428
+ layoutShifts,
429
+ eventCount: events.length,
430
+ };
431
+ }
432
+
433
+ export interface PerformanceAnalyzeInsightArgs {
434
+ /** Path to a trace JSON written by performanceTrace/performanceStopTrace. */
435
+ tracePath?: string;
436
+ }
437
+
438
+ /**
439
+ * CDP-native approximation of the MCP insight analyzer. Reads a trace JSON file
440
+ * (bare array OR { traceEvents: [...] }) and returns structured headline
441
+ * metrics. If `tracePath` is omitted, throws — there is no implicit "latest
442
+ * trace" magic; callers pass the path returned by a trace tool.
443
+ */
444
+ export async function performanceAnalyzeInsight(
445
+ args: PerformanceAnalyzeInsightArgs = {},
446
+ ): Promise<TraceMetrics & { tracePath: string }> {
447
+ if (!args.tracePath) {
448
+ throw new CdpError("performanceAnalyzeInsight requires { tracePath } pointing at a trace JSON file");
449
+ }
450
+ let raw: string;
451
+ try {
452
+ raw = await readFile(args.tracePath, "utf8");
453
+ } catch (err) {
454
+ throw new CdpError(`cannot read trace file '${args.tracePath}': ${(err as Error).message}`);
455
+ }
456
+ let parsed: unknown;
457
+ try {
458
+ parsed = JSON.parse(raw);
459
+ } catch (err) {
460
+ throw new CdpError(`trace file '${args.tracePath}' is not valid JSON: ${(err as Error).message}`);
461
+ }
462
+ const events: TraceEvent[] = Array.isArray(parsed)
463
+ ? (parsed as TraceEvent[])
464
+ : Array.isArray((parsed as { traceEvents?: unknown }).traceEvents)
465
+ ? ((parsed as { traceEvents: TraceEvent[] }).traceEvents)
466
+ : (() => {
467
+ throw new CdpError(`trace file '${args.tracePath}' has no traceEvents array`);
468
+ })();
469
+
470
+ return { ...analyzeEvents(events), tracePath: args.tracePath };
471
+ }
472
+
473
+ /*
474
+ * CDP methods/domains used:
475
+ * - Tracing.start (traceConfig.includedCategories, transferMode:ReportEvents)
476
+ * - Tracing.dataCollected (event — buffered into the event array)
477
+ * - Tracing.end
478
+ * - Tracing.tracingComplete (event — awaited to know recording is flushed)
479
+ * - Page.enable / Page.navigate / Page.reload / Page.loadEventFired (one-shot reload path)
480
+ *
481
+ * Parity gaps vs chrome-devtools-mcp performance tools:
482
+ * - performance_start_trace/performance_stop_trace work only WITHIN one process
483
+ * (a live CDP trace buffer is bound to its connection and cannot be re-attached
484
+ * cross-process); performanceTrace is the robust single-call alternative.
485
+ * - performance_analyze_insight is a CDP-native APPROXIMATION: it parses raw trace
486
+ * events rather than running DevTools' Trace Engine, so FCP/LCP/CLS/TBT are close
487
+ * but not byte-identical (no main-thread attribution, frame-scoped LCP, or
488
+ * soft-navigation handling; no named "insights"/recommendations).
489
+ * - We do not auto-discover the "latest" trace; analyze requires an explicit tracePath.
490
+ * - No CPU/network throttle presets applied during the trace (the MCP tool can stage
491
+ * a Lighthouse-style throttled environment); emulation is left to emulation.ts.
492
+ */