@mtharrison/pkg-profiler 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,752 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ let node_inspector_promises = require("node:inspector/promises");
3
+ let node_url = require("node:url");
4
+ let node_fs = require("node:fs");
5
+ let node_path = require("node:path");
6
+
7
+ //#region src/frame-parser.ts
8
+ /**
9
+ * Classify a V8 CPU profiler call frame and convert its URL to a filesystem path.
10
+ *
11
+ * Every sampled frame from the V8 profiler passes through this function first.
12
+ * It determines the frame kind (user code, internal, eval, wasm) and for user
13
+ * frames converts the URL to a filesystem path and builds a human-readable
14
+ * function identifier.
15
+ */
16
+ function parseFrame(frame) {
17
+ const { url, functionName, lineNumber } = frame;
18
+ if (url === "") return { kind: "internal" };
19
+ if (url.startsWith("node:")) return {
20
+ kind: "user",
21
+ filePath: url,
22
+ functionId: `${functionName || "<anonymous>"}:${lineNumber + 1}`
23
+ };
24
+ if (url.startsWith("wasm:")) return { kind: "wasm" };
25
+ if (url.includes("eval")) return { kind: "eval" };
26
+ return {
27
+ kind: "user",
28
+ filePath: url.startsWith("file://") ? (0, node_url.fileURLToPath)(url) : url,
29
+ functionId: `${functionName || "<anonymous>"}:${lineNumber + 1}`
30
+ };
31
+ }
32
+
33
+ //#endregion
34
+ //#region src/package-resolver.ts
35
+ /**
36
+ * Resolves an absolute file path to a package name and relative path.
37
+ *
38
+ * For node_modules paths: extracts the package name from the last /node_modules/
39
+ * segment (critical for pnpm virtual store compatibility). Handles scoped packages.
40
+ *
41
+ * For first-party files: walks up directory tree looking for package.json,
42
+ * falls back to 'app' if none found.
43
+ */
44
+ var PackageResolver = class {
45
+ projectRoot;
46
+ packageJsonCache = /* @__PURE__ */ new Map();
47
+ constructor(projectRoot) {
48
+ this.projectRoot = projectRoot;
49
+ }
50
+ resolve(absoluteFilePath) {
51
+ const nodeModulesSeg = `${node_path.sep}node_modules${node_path.sep}`;
52
+ const lastIdx = absoluteFilePath.lastIndexOf(nodeModulesSeg);
53
+ if (lastIdx !== -1) {
54
+ const segments = absoluteFilePath.substring(lastIdx + nodeModulesSeg.length).split(node_path.sep);
55
+ let packageName;
56
+ let fileStartIdx;
57
+ if (segments[0].startsWith("@")) {
58
+ packageName = `${segments[0]}/${segments[1]}`;
59
+ fileStartIdx = 2;
60
+ } else {
61
+ packageName = segments[0];
62
+ fileStartIdx = 1;
63
+ }
64
+ const relativePath = segments.slice(fileStartIdx).join("/");
65
+ return {
66
+ packageName,
67
+ relativePath
68
+ };
69
+ }
70
+ return {
71
+ packageName: this.findPackageName(absoluteFilePath),
72
+ relativePath: (0, node_path.relative)(this.projectRoot, absoluteFilePath).split(node_path.sep).join("/")
73
+ };
74
+ }
75
+ /**
76
+ * Walk up from the file's directory looking for package.json.
77
+ * Cache results to avoid repeated filesystem reads.
78
+ */
79
+ findPackageName(absoluteFilePath) {
80
+ let dir = (0, node_path.dirname)(absoluteFilePath);
81
+ while (true) {
82
+ const cached = this.packageJsonCache.get(dir);
83
+ if (cached !== void 0) {
84
+ if (cached !== null) return cached;
85
+ } else try {
86
+ const raw = (0, node_fs.readFileSync)((0, node_path.join)(dir, "package.json"), "utf-8");
87
+ const name = JSON.parse(raw).name ?? null;
88
+ this.packageJsonCache.set(dir, name);
89
+ if (name !== null) return name;
90
+ } catch {
91
+ this.packageJsonCache.set(dir, null);
92
+ }
93
+ const parent = (0, node_path.dirname)(dir);
94
+ if (parent === dir) return "app";
95
+ dir = parent;
96
+ }
97
+ }
98
+ };
99
+
100
+ //#endregion
101
+ //#region src/reporter/aggregate.ts
102
+ const THRESHOLD_PCT = .05;
103
+ /**
104
+ * Aggregate SampleStore data into a ReportData structure.
105
+ *
106
+ * @param store - SampleStore with accumulated microseconds and sample counts
107
+ * @param projectName - Name of the first-party project (for isFirstParty flag)
108
+ * @returns ReportData with packages sorted desc by time, thresholded at 5%
109
+ */
110
+ function aggregate(store, projectName) {
111
+ let totalTimeUs = 0;
112
+ for (const fileMap of store.packages.values()) for (const funcMap of fileMap.values()) for (const us of funcMap.values()) totalTimeUs += us;
113
+ if (totalTimeUs === 0) return {
114
+ timestamp: (/* @__PURE__ */ new Date()).toLocaleString(),
115
+ totalTimeUs: 0,
116
+ packages: [],
117
+ otherCount: 0
118
+ };
119
+ const threshold = totalTimeUs * THRESHOLD_PCT;
120
+ const packages = [];
121
+ let topLevelOtherCount = 0;
122
+ for (const [packageName, fileMap] of store.packages) {
123
+ let packageTimeUs = 0;
124
+ for (const funcMap of fileMap.values()) for (const us of funcMap.values()) packageTimeUs += us;
125
+ if (packageTimeUs < threshold) {
126
+ topLevelOtherCount++;
127
+ continue;
128
+ }
129
+ let packageSampleCount = 0;
130
+ const countFileMap = store.sampleCountsByPackage.get(packageName);
131
+ if (countFileMap) for (const countFuncMap of countFileMap.values()) for (const count of countFuncMap.values()) packageSampleCount += count;
132
+ const files = [];
133
+ let fileOtherCount = 0;
134
+ for (const [fileName, funcMap] of fileMap) {
135
+ let fileTimeUs = 0;
136
+ for (const us of funcMap.values()) fileTimeUs += us;
137
+ if (fileTimeUs < threshold) {
138
+ fileOtherCount++;
139
+ continue;
140
+ }
141
+ let fileSampleCount = 0;
142
+ const countFuncMap = countFileMap?.get(fileName);
143
+ if (countFuncMap) for (const count of countFuncMap.values()) fileSampleCount += count;
144
+ const functions = [];
145
+ let funcOtherCount = 0;
146
+ for (const [funcName, funcTimeUs] of funcMap) {
147
+ if (funcTimeUs < threshold) {
148
+ funcOtherCount++;
149
+ continue;
150
+ }
151
+ const funcSampleCount = countFuncMap?.get(funcName) ?? 0;
152
+ functions.push({
153
+ name: funcName,
154
+ timeUs: funcTimeUs,
155
+ pct: funcTimeUs / totalTimeUs * 100,
156
+ sampleCount: funcSampleCount
157
+ });
158
+ }
159
+ functions.sort((a, b) => b.timeUs - a.timeUs);
160
+ files.push({
161
+ name: fileName,
162
+ timeUs: fileTimeUs,
163
+ pct: fileTimeUs / totalTimeUs * 100,
164
+ sampleCount: fileSampleCount,
165
+ functions,
166
+ otherCount: funcOtherCount
167
+ });
168
+ }
169
+ files.sort((a, b) => b.timeUs - a.timeUs);
170
+ packages.push({
171
+ name: packageName,
172
+ timeUs: packageTimeUs,
173
+ pct: packageTimeUs / totalTimeUs * 100,
174
+ isFirstParty: packageName === projectName,
175
+ sampleCount: packageSampleCount,
176
+ files,
177
+ otherCount: fileOtherCount
178
+ });
179
+ }
180
+ packages.sort((a, b) => b.timeUs - a.timeUs);
181
+ return {
182
+ timestamp: (/* @__PURE__ */ new Date()).toLocaleString(),
183
+ totalTimeUs,
184
+ packages,
185
+ otherCount: topLevelOtherCount
186
+ };
187
+ }
188
+
189
+ //#endregion
190
+ //#region src/reporter/format.ts
191
+ /**
192
+ * Format utilities for the HTML reporter.
193
+ * Pure functions with defined input/output contracts.
194
+ */
195
+ /**
196
+ * Convert microseconds to adaptive human-readable time string.
197
+ *
198
+ * - >= 1s: shows seconds with 2 decimal places (e.g. "1.24s")
199
+ * - < 1s: shows rounded milliseconds (e.g. "432ms")
200
+ * - Sub-millisecond values round up to 1ms (never shows "0ms" for nonzero input)
201
+ * - Zero returns "0ms"
202
+ */
203
+ function formatTime(us) {
204
+ if (us === 0) return "0ms";
205
+ const ms = us / 1e3;
206
+ if (ms >= 1e3) return `${(ms / 1e3).toFixed(2)}s`;
207
+ const rounded = Math.round(ms);
208
+ return `${rounded < 1 ? 1 : rounded}ms`;
209
+ }
210
+ /**
211
+ * Convert microseconds to percentage of total with one decimal place.
212
+ * Returns "0.0%" when totalUs is zero (avoids division by zero).
213
+ */
214
+ function formatPct(us, totalUs) {
215
+ if (totalUs === 0) return "0.0%";
216
+ return `${(us / totalUs * 100).toFixed(1)}%`;
217
+ }
218
+ /**
219
+ * Escape HTML-special characters to prevent broken markup.
220
+ * Handles: & < > " '
221
+ * Ampersand is replaced first to avoid double-escaping.
222
+ */
223
+ function escapeHtml(str) {
224
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
225
+ }
226
+
227
+ //#endregion
228
+ //#region src/reporter/html.ts
229
+ function generateCss() {
230
+ return `
231
+ :root {
232
+ --bg: #fafbfc;
233
+ --text: #1a1a2e;
234
+ --muted: #8b8fa3;
235
+ --border: #e2e4ea;
236
+ --first-party-accent: #3b6cf5;
237
+ --first-party-bg: #eef2ff;
238
+ --dep-bg: #ffffff;
239
+ --bar-track: #e8eaed;
240
+ --bar-fill: #5b8def;
241
+ --bar-fill-fp: #3b6cf5;
242
+ --other-text: #a0a4b8;
243
+ --table-header-bg: #f4f5f7;
244
+ --shadow: 0 1px 3px rgba(0,0,0,0.06);
245
+ --radius: 6px;
246
+ --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace;
247
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
248
+ }
249
+
250
+ * { margin: 0; padding: 0; box-sizing: border-box; }
251
+
252
+ body {
253
+ font-family: var(--font-sans);
254
+ background: var(--bg);
255
+ color: var(--text);
256
+ line-height: 1.5;
257
+ padding: 2rem;
258
+ max-width: 960px;
259
+ margin: 0 auto;
260
+ }
261
+
262
+ h1 {
263
+ font-size: 1.5rem;
264
+ font-weight: 600;
265
+ margin-bottom: 0.25rem;
266
+ }
267
+
268
+ .meta {
269
+ color: var(--muted);
270
+ font-size: 0.85rem;
271
+ margin-bottom: 2rem;
272
+ }
273
+
274
+ h2 {
275
+ font-size: 1.1rem;
276
+ font-weight: 600;
277
+ margin-bottom: 0.75rem;
278
+ margin-top: 2rem;
279
+ }
280
+
281
+ /* Summary table */
282
+ table {
283
+ width: 100%;
284
+ border-collapse: collapse;
285
+ background: #fff;
286
+ border-radius: var(--radius);
287
+ box-shadow: var(--shadow);
288
+ overflow: hidden;
289
+ margin-bottom: 1rem;
290
+ }
291
+
292
+ th {
293
+ text-align: left;
294
+ background: var(--table-header-bg);
295
+ padding: 0.6rem 0.75rem;
296
+ font-size: 0.8rem;
297
+ font-weight: 600;
298
+ text-transform: uppercase;
299
+ letter-spacing: 0.04em;
300
+ color: var(--muted);
301
+ border-bottom: 1px solid var(--border);
302
+ }
303
+
304
+ td {
305
+ padding: 0.55rem 0.75rem;
306
+ border-bottom: 1px solid var(--border);
307
+ font-size: 0.9rem;
308
+ }
309
+
310
+ tr:last-child td { border-bottom: none; }
311
+
312
+ tr.first-party td:first-child {
313
+ border-left: 3px solid var(--first-party-accent);
314
+ padding-left: calc(0.75rem - 3px);
315
+ }
316
+
317
+ td.pkg-name { font-family: var(--font-mono); font-size: 0.85rem; }
318
+ td.numeric { text-align: right; font-family: var(--font-mono); font-size: 0.85rem; }
319
+
320
+ .bar-cell {
321
+ width: 30%;
322
+ padding-right: 1rem;
323
+ }
324
+
325
+ .bar-container {
326
+ display: flex;
327
+ align-items: center;
328
+ gap: 0.5rem;
329
+ }
330
+
331
+ .bar-track {
332
+ flex: 1;
333
+ height: 8px;
334
+ background: var(--bar-track);
335
+ border-radius: 4px;
336
+ overflow: hidden;
337
+ }
338
+
339
+ .bar-fill {
340
+ height: 100%;
341
+ border-radius: 4px;
342
+ background: var(--bar-fill);
343
+ min-width: 1px;
344
+ }
345
+
346
+ tr.first-party .bar-fill {
347
+ background: var(--bar-fill-fp);
348
+ }
349
+
350
+ .bar-pct {
351
+ font-family: var(--font-mono);
352
+ font-size: 0.8rem;
353
+ min-width: 3.5em;
354
+ text-align: right;
355
+ }
356
+
357
+ tr.other-row td {
358
+ color: var(--other-text);
359
+ font-style: italic;
360
+ }
361
+
362
+ /* Tree */
363
+ .tree {
364
+ background: #fff;
365
+ border-radius: var(--radius);
366
+ box-shadow: var(--shadow);
367
+ overflow: hidden;
368
+ }
369
+
370
+ details {
371
+ border-bottom: 1px solid var(--border);
372
+ }
373
+
374
+ details:last-child { border-bottom: none; }
375
+
376
+ details details { border-bottom: 1px solid var(--border); }
377
+ details details:last-child { border-bottom: none; }
378
+
379
+ summary {
380
+ cursor: pointer;
381
+ list-style: none;
382
+ padding: 0.6rem 0.75rem;
383
+ display: flex;
384
+ align-items: center;
385
+ gap: 0.5rem;
386
+ font-size: 0.9rem;
387
+ user-select: none;
388
+ }
389
+
390
+ summary::-webkit-details-marker { display: none; }
391
+
392
+ summary::before {
393
+ content: '\\25B6';
394
+ font-size: 0.6rem;
395
+ color: var(--muted);
396
+ transition: transform 0.15s ease;
397
+ flex-shrink: 0;
398
+ }
399
+
400
+ details[open] > summary::before {
401
+ transform: rotate(90deg);
402
+ }
403
+
404
+ .tree-name {
405
+ font-family: var(--font-mono);
406
+ font-size: 0.85rem;
407
+ flex: 1;
408
+ }
409
+
410
+ .tree-label {
411
+ font-family: var(--font-sans);
412
+ font-size: 0.65rem;
413
+ font-weight: 600;
414
+ text-transform: uppercase;
415
+ letter-spacing: 0.04em;
416
+ padding: 0.1rem 0.35rem;
417
+ border-radius: 3px;
418
+ flex-shrink: 0;
419
+ }
420
+
421
+ .tree-label.pkg { background: #e8eaed; color: #555; }
422
+ .tree-label.file { background: #e8f0fe; color: #3b6cf5; }
423
+ .tree-label.fn { background: #f0f0f0; color: #777; }
424
+
425
+ .tree-stats {
426
+ font-family: var(--font-mono);
427
+ font-size: 0.8rem;
428
+ color: var(--muted);
429
+ flex-shrink: 0;
430
+ }
431
+
432
+ /* Level indentation */
433
+ .level-0 > summary { padding-left: 0.75rem; }
434
+ .level-1 > summary { padding-left: 2rem; }
435
+ .level-2 { padding: 0.45rem 0.75rem 0.45rem 3.25rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; }
436
+
437
+ /* First-party package highlight */
438
+ .fp-pkg > summary {
439
+ background: var(--first-party-bg);
440
+ border-left: 3px solid var(--first-party-accent);
441
+ }
442
+
443
+ .other-item {
444
+ padding: 0.45rem 0.75rem;
445
+ color: var(--other-text);
446
+ font-style: italic;
447
+ font-size: 0.85rem;
448
+ }
449
+
450
+ .other-item.indent-1 { padding-left: 2rem; }
451
+ .other-item.indent-2 { padding-left: 3.25rem; }
452
+
453
+ @media (max-width: 600px) {
454
+ body { padding: 1rem; }
455
+ .bar-cell { width: 25%; }
456
+ }
457
+ `;
458
+ }
459
+ function renderSummaryTable(packages, otherCount, totalTimeUs) {
460
+ let rows = "";
461
+ for (const pkg of packages) {
462
+ const cls = pkg.isFirstParty ? "first-party" : "dependency";
463
+ const pctVal = totalTimeUs > 0 ? pkg.timeUs / totalTimeUs * 100 : 0;
464
+ rows += `
465
+ <tr class="${cls}">
466
+ <td class="pkg-name">${escapeHtml(pkg.name)}</td>
467
+ <td class="numeric">${escapeHtml(formatTime(pkg.timeUs))}</td>
468
+ <td class="bar-cell">
469
+ <div class="bar-container">
470
+ <div class="bar-track"><div class="bar-fill" style="width:${pctVal.toFixed(1)}%"></div></div>
471
+ <span class="bar-pct">${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))}</span>
472
+ </div>
473
+ </td>
474
+ <td class="numeric">${pkg.sampleCount}</td>
475
+ </tr>`;
476
+ }
477
+ if (otherCount > 0) rows += `
478
+ <tr class="other-row">
479
+ <td class="pkg-name">Other (${otherCount} items)</td>
480
+ <td class="numeric"></td>
481
+ <td class="bar-cell"></td>
482
+ <td class="numeric"></td>
483
+ </tr>`;
484
+ return `
485
+ <table>
486
+ <thead>
487
+ <tr>
488
+ <th>Package</th>
489
+ <th>Wall Time</th>
490
+ <th>% of Total</th>
491
+ <th>Samples</th>
492
+ </tr>
493
+ </thead>
494
+ <tbody>${rows}
495
+ </tbody>
496
+ </table>`;
497
+ }
498
+ function renderTree(packages, otherCount, totalTimeUs) {
499
+ let html = "<div class=\"tree\">";
500
+ for (const pkg of packages) {
501
+ const fpCls = pkg.isFirstParty ? " fp-pkg" : "";
502
+ html += `<details class="level-0${fpCls}">`;
503
+ html += `<summary>`;
504
+ html += `<span class="tree-label pkg">pkg</span>`;
505
+ html += `<span class="tree-name">${escapeHtml(pkg.name)}</span>`;
506
+ html += `<span class="tree-stats">${escapeHtml(formatTime(pkg.timeUs))} &middot; ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} &middot; ${pkg.sampleCount} samples</span>`;
507
+ html += `</summary>`;
508
+ for (const file of pkg.files) {
509
+ html += `<details class="level-1">`;
510
+ html += `<summary>`;
511
+ html += `<span class="tree-label file">file</span>`;
512
+ html += `<span class="tree-name">${escapeHtml(file.name)}</span>`;
513
+ html += `<span class="tree-stats">${escapeHtml(formatTime(file.timeUs))} &middot; ${escapeHtml(formatPct(file.timeUs, totalTimeUs))} &middot; ${file.sampleCount} samples</span>`;
514
+ html += `</summary>`;
515
+ for (const fn of file.functions) {
516
+ html += `<div class="level-2">`;
517
+ html += `<span class="tree-label fn">fn</span> `;
518
+ html += `<span class="tree-name">${escapeHtml(fn.name)}</span>`;
519
+ html += ` <span class="tree-stats">${escapeHtml(formatTime(fn.timeUs))} &middot; ${escapeHtml(formatPct(fn.timeUs, totalTimeUs))} &middot; ${fn.sampleCount} samples</span>`;
520
+ html += `</div>`;
521
+ }
522
+ if (file.otherCount > 0) html += `<div class="other-item indent-2">Other (${file.otherCount} items)</div>`;
523
+ html += `</details>`;
524
+ }
525
+ if (pkg.otherCount > 0) html += `<div class="other-item indent-1">Other (${pkg.otherCount} items)</div>`;
526
+ html += `</details>`;
527
+ }
528
+ if (otherCount > 0) html += `<div class="other-item">Other (${otherCount} packages)</div>`;
529
+ html += "</div>";
530
+ return html;
531
+ }
532
+ /**
533
+ * Render a complete self-contained HTML report from aggregated profiling data.
534
+ */
535
+ function renderHtml(data) {
536
+ const summaryTable = renderSummaryTable(data.packages, data.otherCount, data.totalTimeUs);
537
+ const tree = renderTree(data.packages, data.otherCount, data.totalTimeUs);
538
+ const totalFormatted = escapeHtml(formatTime(data.totalTimeUs));
539
+ return `<!DOCTYPE html>
540
+ <html lang="en">
541
+ <head>
542
+ <meta charset="utf-8">
543
+ <meta name="viewport" content="width=device-width, initial-scale=1">
544
+ <title>where-you-at report</title>
545
+ <style>${generateCss()}
546
+ </style>
547
+ </head>
548
+ <body>
549
+ <h1>where-you-at</h1>
550
+ <div class="meta">Generated ${escapeHtml(data.timestamp)} &middot; Total wall time: ${totalFormatted}</div>
551
+
552
+ <h2>Summary</h2>
553
+ ${summaryTable}
554
+
555
+ <h2>Details</h2>
556
+ ${tree}
557
+ </body>
558
+ </html>`;
559
+ }
560
+
561
+ //#endregion
562
+ //#region src/reporter.ts
563
+ /**
564
+ * Reporter orchestrator.
565
+ *
566
+ * Aggregates SampleStore data, renders HTML, writes file to cwd,
567
+ * and returns the file path.
568
+ */
569
+ function generateFilename() {
570
+ const now = /* @__PURE__ */ new Date();
571
+ const pad = (n) => String(n).padStart(2, "0");
572
+ return `where-you-at-${`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`}-${`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`}.html`;
573
+ }
574
+ function readProjectName(cwd) {
575
+ try {
576
+ const raw = (0, node_fs.readFileSync)((0, node_path.join)(cwd, "package.json"), "utf-8");
577
+ return JSON.parse(raw).name ?? "app";
578
+ } catch {
579
+ return "app";
580
+ }
581
+ }
582
+ function generateReport(store, cwd) {
583
+ const resolvedCwd = cwd ?? process.cwd();
584
+ const html = renderHtml(aggregate(store, readProjectName(resolvedCwd)));
585
+ const filename = generateFilename();
586
+ const filepath = (0, node_path.join)(resolvedCwd, filename);
587
+ (0, node_fs.writeFileSync)(filepath, html, "utf-8");
588
+ console.log(`Report written to ./${filename}`);
589
+ return filepath;
590
+ }
591
+
592
+ //#endregion
593
+ //#region src/sample-store.ts
594
+ /**
595
+ * Accumulates per-package wall time (microseconds) from the V8 CPU profiler.
596
+ *
597
+ * Data structure: nested Maps -- package -> file -> function -> microseconds.
598
+ * This naturally matches the package-first tree output that the reporter
599
+ * needs in Phase 3. O(1) lookups at each level, no serialization overhead.
600
+ *
601
+ * A parallel sampleCounts structure tracks raw sample counts (incremented by 1
602
+ * per record() call) for the summary table's "Sample count" column.
603
+ */
604
+ var SampleStore = class {
605
+ data = /* @__PURE__ */ new Map();
606
+ counts = /* @__PURE__ */ new Map();
607
+ internalCount = 0;
608
+ internalSamples = 0;
609
+ /**
610
+ * Record a sample for a user-code frame.
611
+ * Accumulates deltaUs microseconds for the given (package, file, function) triple,
612
+ * and increments the parallel sample count by 1.
613
+ */
614
+ record(packageName, relativePath, functionId, deltaUs) {
615
+ let fileMap = this.data.get(packageName);
616
+ if (fileMap === void 0) {
617
+ fileMap = /* @__PURE__ */ new Map();
618
+ this.data.set(packageName, fileMap);
619
+ }
620
+ let funcMap = fileMap.get(relativePath);
621
+ if (funcMap === void 0) {
622
+ funcMap = /* @__PURE__ */ new Map();
623
+ fileMap.set(relativePath, funcMap);
624
+ }
625
+ funcMap.set(functionId, (funcMap.get(functionId) ?? 0) + deltaUs);
626
+ let countFileMap = this.counts.get(packageName);
627
+ if (countFileMap === void 0) {
628
+ countFileMap = /* @__PURE__ */ new Map();
629
+ this.counts.set(packageName, countFileMap);
630
+ }
631
+ let countFuncMap = countFileMap.get(relativePath);
632
+ if (countFuncMap === void 0) {
633
+ countFuncMap = /* @__PURE__ */ new Map();
634
+ countFileMap.set(relativePath, countFuncMap);
635
+ }
636
+ countFuncMap.set(functionId, (countFuncMap.get(functionId) ?? 0) + 1);
637
+ }
638
+ /** Record an internal/filtered frame (empty URL, eval, wasm, idle, etc). */
639
+ recordInternal(deltaUs) {
640
+ this.internalCount += deltaUs;
641
+ this.internalSamples += 1;
642
+ }
643
+ /** Reset all accumulated data to a clean state. */
644
+ clear() {
645
+ this.data = /* @__PURE__ */ new Map();
646
+ this.counts = /* @__PURE__ */ new Map();
647
+ this.internalCount = 0;
648
+ this.internalSamples = 0;
649
+ }
650
+ /** Read-only access to the accumulated sample data (microseconds). */
651
+ get packages() {
652
+ return this.data;
653
+ }
654
+ /** Count of internal/filtered microseconds recorded. */
655
+ get internal() {
656
+ return this.internalCount;
657
+ }
658
+ /** Read-only access to the parallel sample counts. */
659
+ get sampleCountsByPackage() {
660
+ return this.counts;
661
+ }
662
+ /** Count of internal/filtered samples (raw count, not microseconds). */
663
+ get internalSampleCount() {
664
+ return this.internalSamples;
665
+ }
666
+ };
667
+
668
+ //#endregion
669
+ //#region src/sampler.ts
670
+ let session = null;
671
+ let profiling = false;
672
+ const store = new SampleStore();
673
+ const resolver = new PackageResolver(process.cwd());
674
+ /**
675
+ * Start the V8 CPU profiler. If already profiling, this is a safe no-op.
676
+ */
677
+ async function track(options) {
678
+ if (profiling) return;
679
+ if (session === null) {
680
+ session = new node_inspector_promises.Session();
681
+ session.connect();
682
+ }
683
+ await session.post("Profiler.enable");
684
+ if (options?.interval !== void 0) await session.post("Profiler.setSamplingInterval", { interval: options.interval });
685
+ await session.post("Profiler.start");
686
+ profiling = true;
687
+ }
688
+ /**
689
+ * Stop the profiler (if running) and reset all accumulated sample data.
690
+ */
691
+ async function clear() {
692
+ if (profiling && session) {
693
+ await session.post("Profiler.stop");
694
+ await session.post("Profiler.disable");
695
+ profiling = false;
696
+ }
697
+ store.clear();
698
+ }
699
+ /**
700
+ * Stop the profiler, process collected samples through the data pipeline
701
+ * (parseFrame -> PackageResolver -> SampleStore), generate an HTML report,
702
+ * and return the file path. Resets the store after reporting (clean slate
703
+ * for next cycle).
704
+ *
705
+ * Returns the absolute path to the generated HTML file, or empty string
706
+ * if no samples were collected.
707
+ */
708
+ async function report() {
709
+ if (!profiling || !session) {
710
+ console.log("no samples collected");
711
+ return "";
712
+ }
713
+ const { profile } = await session.post("Profiler.stop");
714
+ await session.post("Profiler.disable");
715
+ profiling = false;
716
+ processProfile(profile);
717
+ let filepath = "";
718
+ if (store.packages.size > 0) filepath = generateReport(store);
719
+ else console.log("no samples collected");
720
+ store.clear();
721
+ return filepath;
722
+ }
723
+ /**
724
+ * Process a V8 CPUProfile: walk each sample, parse the frame, resolve
725
+ * the package, and record into the store. Uses timeDeltas for wall-time
726
+ * microsecond accumulation.
727
+ */
728
+ function processProfile(profile) {
729
+ const nodeMap = new Map(profile.nodes.map((n) => [n.id, n]));
730
+ const samples = profile.samples ?? [];
731
+ const timeDeltas = profile.timeDeltas ?? [];
732
+ for (let i = 0; i < samples.length; i++) {
733
+ const node = nodeMap.get(samples[i]);
734
+ if (!node) continue;
735
+ const deltaUs = timeDeltas[i] ?? 0;
736
+ const parsed = parseFrame(node.callFrame);
737
+ if (parsed.kind === "user") if (parsed.filePath.startsWith("node:")) {
738
+ const relativePath = parsed.filePath.slice(5);
739
+ store.record("node (built-in)", relativePath, parsed.functionId, deltaUs);
740
+ } else {
741
+ const { packageName, relativePath } = resolver.resolve(parsed.filePath);
742
+ store.record(packageName, relativePath, parsed.functionId, deltaUs);
743
+ }
744
+ else store.recordInternal(deltaUs);
745
+ }
746
+ }
747
+
748
+ //#endregion
749
+ exports.clear = clear;
750
+ exports.report = report;
751
+ exports.track = track;
752
+ //# sourceMappingURL=index.cjs.map