@mtharrison/pkg-profiler 1.1.0 → 2.1.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.
package/dist/index.js CHANGED
@@ -1,8 +1,222 @@
1
- import { Session } from "node:inspector/promises";
2
- import { fileURLToPath } from "node:url";
3
1
  import { readFileSync, writeFileSync } from "node:fs";
4
- import { dirname, join, relative, sep } from "node:path";
2
+ import { Session } from "node:inspector";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+ import { createHook } from "node:async_hooks";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ //#region src/async-tracker.ts
8
+ /**
9
+ * Opt-in async I/O wait time tracker using node:async_hooks.
10
+ *
11
+ * Tracks the time between async resource init (when the I/O op is started)
12
+ * and the first before callback (when the callback fires), attributing
13
+ * that wait time to the package/file/function that initiated the operation.
14
+ *
15
+ * Intervals are buffered and merged at disable() time so that overlapping
16
+ * concurrent I/O is not double-counted.
17
+ */
18
+ /** Async resource types worth tracking — I/O and timers, not promises. */
19
+ const TRACKED_TYPES = new Set([
20
+ "TCPCONNECTWRAP",
21
+ "TCPWRAP",
22
+ "PIPEWRAP",
23
+ "PIPECONNECTWRAP",
24
+ "TLSWRAP",
25
+ "FSREQCALLBACK",
26
+ "FSREQPROMISE",
27
+ "GETADDRINFOREQWRAP",
28
+ "GETNAMEINFOREQWRAP",
29
+ "HTTPCLIENTREQUEST",
30
+ "HTTPINCOMINGMESSAGE",
31
+ "SHUTDOWNWRAP",
32
+ "WRITEWRAP",
33
+ "ZLIB",
34
+ "Timeout"
35
+ ]);
36
+ /**
37
+ * Merge overlapping or adjacent intervals. Returns a new sorted array
38
+ * of non-overlapping intervals.
39
+ */
40
+ function mergeIntervals(intervals) {
41
+ if (intervals.length <= 1) return intervals.slice();
42
+ const sorted = intervals.slice().sort((a, b) => a.startUs - b.startUs);
43
+ const merged = [{ ...sorted[0] }];
44
+ for (let i = 1; i < sorted.length; i++) {
45
+ const current = sorted[i];
46
+ const last = merged[merged.length - 1];
47
+ if (current.startUs <= last.endUs) {
48
+ if (current.endUs > last.endUs) last.endUs = current.endUs;
49
+ } else merged.push({ ...current });
50
+ }
51
+ return merged;
52
+ }
53
+ /**
54
+ * Sum the durations of a list of (presumably non-overlapping) intervals.
55
+ */
56
+ function sumIntervals(intervals) {
57
+ let total = 0;
58
+ for (const iv of intervals) total += iv.endUs - iv.startUs;
59
+ return total;
60
+ }
61
+ /**
62
+ * Convert an hrtime tuple to absolute microseconds.
63
+ */
64
+ function hrtimeToUs(hr) {
65
+ return hr[0] * 1e6 + Math.round(hr[1] / 1e3);
66
+ }
67
+ /**
68
+ * Parse a single line from an Error().stack trace into file path and function id.
69
+ * Returns null for lines that don't match V8's stack frame format or are node internals.
70
+ *
71
+ * Handles these V8 formats:
72
+ * " at functionName (/absolute/path:line:col)"
73
+ * " at /absolute/path:line:col"
74
+ * " at Object.functionName (/absolute/path:line:col)"
75
+ */
76
+ function parseStackLine(line) {
77
+ const match = line.match(/^\s+at\s+(?:(.+?)\s+\()?(.+?):(\d+):\d+\)?$/);
78
+ if (!match) return null;
79
+ const rawFn = match[1] ?? "";
80
+ const filePath = match[2];
81
+ const lineNum = match[3];
82
+ if (filePath.startsWith("node:") || filePath.startsWith("<")) return null;
83
+ const fnParts = rawFn.split(".");
84
+ return {
85
+ filePath,
86
+ functionId: `${fnParts[fnParts.length - 1] || "<anonymous>"}:${lineNum}`
87
+ };
88
+ }
89
+ var AsyncTracker = class {
90
+ resolver;
91
+ thresholdUs;
92
+ hook = null;
93
+ pending = /* @__PURE__ */ new Map();
94
+ /** Buffered intervals keyed by "pkg\0file\0fn" */
95
+ keyedIntervals = /* @__PURE__ */ new Map();
96
+ /** Flat list of all intervals for global merging */
97
+ globalIntervals = [];
98
+ /** Origin time in absolute microseconds, set when enable() is called */
99
+ originUs = 0;
100
+ /** Merged global total set after flush() */
101
+ _mergedTotalUs = 0;
102
+ /**
103
+ * @param resolver - PackageResolver for mapping file paths to packages
104
+ * @param store - SampleStore to record async wait times into (used at flush time)
105
+ * @param thresholdUs - Minimum wait duration in microseconds to record (default 1000 = 1ms)
106
+ */
107
+ constructor(resolver, store, thresholdUs = 1e3) {
108
+ this.store = store;
109
+ this.resolver = resolver;
110
+ this.thresholdUs = thresholdUs;
111
+ }
112
+ /** Merged global async total in microseconds, available after disable(). */
113
+ get mergedTotalUs() {
114
+ return this._mergedTotalUs;
115
+ }
116
+ enable() {
117
+ if (this.hook) return;
118
+ this.originUs = hrtimeToUs(process.hrtime());
119
+ this.hook = createHook({
120
+ init: (asyncId, type) => {
121
+ if (!TRACKED_TYPES.has(type)) return;
122
+ const holder = {};
123
+ const origLimit = Error.stackTraceLimit;
124
+ Error.stackTraceLimit = 8;
125
+ Error.captureStackTrace(holder);
126
+ Error.stackTraceLimit = origLimit;
127
+ const stack = holder.stack;
128
+ if (!stack) return;
129
+ const lines = stack.split("\n");
130
+ let parsed = null;
131
+ for (let i = 1; i < lines.length; i++) {
132
+ const result = parseStackLine(lines[i]);
133
+ if (result) {
134
+ if (result.filePath.includes("async-tracker")) continue;
135
+ parsed = result;
136
+ break;
137
+ }
138
+ }
139
+ if (!parsed) return;
140
+ const { packageName, relativePath } = this.resolver.resolve(parsed.filePath);
141
+ this.pending.set(asyncId, {
142
+ startHrtime: process.hrtime(),
143
+ pkg: packageName,
144
+ file: relativePath,
145
+ fn: parsed.functionId
146
+ });
147
+ },
148
+ before: (asyncId) => {
149
+ const op = this.pending.get(asyncId);
150
+ if (!op) return;
151
+ const endHr = process.hrtime();
152
+ const startUs = hrtimeToUs(op.startHrtime);
153
+ const endUs = hrtimeToUs(endHr);
154
+ if (endUs - startUs >= this.thresholdUs) {
155
+ const interval = {
156
+ startUs,
157
+ endUs
158
+ };
159
+ const key = `${op.pkg}\0${op.file}\0${op.fn}`;
160
+ let arr = this.keyedIntervals.get(key);
161
+ if (!arr) {
162
+ arr = [];
163
+ this.keyedIntervals.set(key, arr);
164
+ }
165
+ arr.push(interval);
166
+ this.globalIntervals.push(interval);
167
+ }
168
+ this.pending.delete(asyncId);
169
+ },
170
+ destroy: (asyncId) => {
171
+ this.pending.delete(asyncId);
172
+ }
173
+ });
174
+ this.hook.enable();
175
+ }
176
+ disable() {
177
+ if (!this.hook) return;
178
+ this.hook.disable();
179
+ const nowUs = hrtimeToUs(process.hrtime());
180
+ for (const [, op] of this.pending) {
181
+ const startUs = hrtimeToUs(op.startHrtime);
182
+ if (nowUs - startUs >= this.thresholdUs) {
183
+ const interval = {
184
+ startUs,
185
+ endUs: nowUs
186
+ };
187
+ const key = `${op.pkg}\0${op.file}\0${op.fn}`;
188
+ let arr = this.keyedIntervals.get(key);
189
+ if (!arr) {
190
+ arr = [];
191
+ this.keyedIntervals.set(key, arr);
192
+ }
193
+ arr.push(interval);
194
+ this.globalIntervals.push(interval);
195
+ }
196
+ }
197
+ this.pending.clear();
198
+ this.hook = null;
199
+ this.flush();
200
+ }
201
+ /**
202
+ * Merge buffered intervals and record to the store.
203
+ * Sets mergedTotalUs to the global merged duration.
204
+ */
205
+ flush() {
206
+ for (const [key, intervals] of this.keyedIntervals) {
207
+ const totalUs = sumIntervals(mergeIntervals(intervals));
208
+ if (totalUs > 0) {
209
+ const parts = key.split("\0");
210
+ this.store.record(parts[0], parts[1], parts[2], totalUs);
211
+ }
212
+ }
213
+ this._mergedTotalUs = sumIntervals(mergeIntervals(this.globalIntervals));
214
+ this.keyedIntervals.clear();
215
+ this.globalIntervals = [];
216
+ }
217
+ };
5
218
 
219
+ //#endregion
6
220
  //#region src/frame-parser.ts
7
221
  /**
8
222
  * Classify a V8 CPU profiler call frame and convert its URL to a filesystem path.
@@ -11,6 +225,9 @@ import { dirname, join, relative, sep } from "node:path";
11
225
  * It determines the frame kind (user code, internal, eval, wasm) and for user
12
226
  * frames converts the URL to a filesystem path and builds a human-readable
13
227
  * function identifier.
228
+ *
229
+ * @param frame - Raw call frame from the V8 CPU profiler.
230
+ * @returns A classified frame: `'user'` with file path and function id, or a non-user kind.
14
231
  */
15
232
  function parseFrame(frame) {
16
233
  const { url, functionName, lineNumber } = frame;
@@ -96,97 +313,6 @@ var PackageResolver = class {
96
313
  }
97
314
  };
98
315
 
99
- //#endregion
100
- //#region src/reporter/aggregate.ts
101
- const THRESHOLD_PCT = .05;
102
- /**
103
- * Aggregate SampleStore data into a ReportData structure.
104
- *
105
- * @param store - SampleStore with accumulated microseconds and sample counts
106
- * @param projectName - Name of the first-party project (for isFirstParty flag)
107
- * @returns ReportData with packages sorted desc by time, thresholded at 5%
108
- */
109
- function aggregate(store, projectName) {
110
- let totalTimeUs = 0;
111
- for (const fileMap of store.packages.values()) for (const funcMap of fileMap.values()) for (const us of funcMap.values()) totalTimeUs += us;
112
- if (totalTimeUs === 0) return {
113
- timestamp: (/* @__PURE__ */ new Date()).toLocaleString(),
114
- totalTimeUs: 0,
115
- packages: [],
116
- otherCount: 0,
117
- projectName
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
- projectName
187
- };
188
- }
189
-
190
316
  //#endregion
191
317
  //#region src/reporter/format.ts
192
318
  /**
@@ -200,6 +326,9 @@ function aggregate(store, projectName) {
200
326
  * - < 1s: shows rounded milliseconds (e.g. "432ms")
201
327
  * - Sub-millisecond values round up to 1ms (never shows "0ms" for nonzero input)
202
328
  * - Zero returns "0ms"
329
+ *
330
+ * @param us - Time value in microseconds.
331
+ * @returns Human-readable time string.
203
332
  */
204
333
  function formatTime(us) {
205
334
  if (us === 0) return "0ms";
@@ -211,6 +340,10 @@ function formatTime(us) {
211
340
  /**
212
341
  * Convert microseconds to percentage of total with one decimal place.
213
342
  * Returns "0.0%" when totalUs is zero (avoids division by zero).
343
+ *
344
+ * @param us - Time value in microseconds.
345
+ * @param totalUs - Total time in microseconds (denominator).
346
+ * @returns Percentage string like `"12.3%"`.
214
347
  */
215
348
  function formatPct(us, totalUs) {
216
349
  if (totalUs === 0) return "0.0%";
@@ -220,6 +353,9 @@ function formatPct(us, totalUs) {
220
353
  * Escape HTML-special characters to prevent broken markup.
221
354
  * Handles: & < > " '
222
355
  * Ampersand is replaced first to avoid double-escaping.
356
+ *
357
+ * @param str - Raw string to escape.
358
+ * @returns HTML-safe string.
223
359
  */
224
360
  function escapeHtml(str) {
225
361
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -240,6 +376,7 @@ function generateCss() {
240
376
  --bar-track: #e8eaed;
241
377
  --bar-fill: #5b8def;
242
378
  --bar-fill-fp: #3b6cf5;
379
+ --bar-fill-async: #f5943b;
243
380
  --other-text: #a0a4b8;
244
381
  --table-header-bg: #f4f5f7;
245
382
  --shadow: 0 1px 3px rgba(0,0,0,0.06);
@@ -279,6 +416,59 @@ function generateCss() {
279
416
  margin-top: 2rem;
280
417
  }
281
418
 
419
+ /* Threshold slider */
420
+ .threshold-control {
421
+ display: flex;
422
+ align-items: center;
423
+ gap: 0.75rem;
424
+ margin-bottom: 1rem;
425
+ font-size: 0.85rem;
426
+ }
427
+
428
+ .threshold-control label {
429
+ font-weight: 600;
430
+ color: var(--muted);
431
+ text-transform: uppercase;
432
+ letter-spacing: 0.04em;
433
+ font-size: 0.8rem;
434
+ }
435
+
436
+ .threshold-control input[type="range"] {
437
+ flex: 1;
438
+ max-width: 240px;
439
+ height: 8px;
440
+ appearance: none;
441
+ -webkit-appearance: none;
442
+ background: var(--bar-track);
443
+ border-radius: 4px;
444
+ outline: none;
445
+ }
446
+
447
+ .threshold-control input[type="range"]::-webkit-slider-thumb {
448
+ appearance: none;
449
+ -webkit-appearance: none;
450
+ width: 16px;
451
+ height: 16px;
452
+ border-radius: 50%;
453
+ background: var(--bar-fill);
454
+ cursor: pointer;
455
+ }
456
+
457
+ .threshold-control input[type="range"]::-moz-range-thumb {
458
+ width: 16px;
459
+ height: 16px;
460
+ border-radius: 50%;
461
+ background: var(--bar-fill);
462
+ cursor: pointer;
463
+ border: none;
464
+ }
465
+
466
+ .threshold-control span {
467
+ font-family: var(--font-mono);
468
+ font-size: 0.85rem;
469
+ min-width: 3.5em;
470
+ }
471
+
282
472
  /* Summary table */
283
473
  table {
284
474
  width: 100%;
@@ -317,6 +507,7 @@ function generateCss() {
317
507
 
318
508
  td.pkg-name { font-family: var(--font-mono); font-size: 0.85rem; }
319
509
  td.numeric { text-align: right; font-family: var(--font-mono); font-size: 0.85rem; }
510
+ td.async-col { color: var(--bar-fill-async); }
320
511
 
321
512
  .bar-cell {
322
513
  width: 30%;
@@ -430,6 +621,13 @@ function generateCss() {
430
621
  flex-shrink: 0;
431
622
  }
432
623
 
624
+ .tree-async {
625
+ font-family: var(--font-mono);
626
+ font-size: 0.8rem;
627
+ color: var(--bar-fill-async);
628
+ flex-shrink: 0;
629
+ }
630
+
433
631
  /* Level indentation */
434
632
  .level-0 > summary { padding-left: 0.75rem; }
435
633
  .level-1 > summary { padding-left: 2rem; }
@@ -451,13 +649,330 @@ function generateCss() {
451
649
  .other-item.indent-1 { padding-left: 2rem; }
452
650
  .other-item.indent-2 { padding-left: 3.25rem; }
453
651
 
652
+ /* Sort control */
653
+ .sort-control {
654
+ display: inline-flex;
655
+ align-items: center;
656
+ gap: 0.5rem;
657
+ margin-left: 1.5rem;
658
+ font-size: 0.85rem;
659
+ }
660
+
661
+ .sort-control label {
662
+ font-weight: 600;
663
+ color: var(--muted);
664
+ text-transform: uppercase;
665
+ letter-spacing: 0.04em;
666
+ font-size: 0.8rem;
667
+ }
668
+
669
+ .sort-toggle {
670
+ display: inline-flex;
671
+ border: 1px solid var(--border);
672
+ border-radius: 4px;
673
+ overflow: hidden;
674
+ }
675
+
676
+ .sort-toggle button {
677
+ font-family: var(--font-sans);
678
+ font-size: 0.8rem;
679
+ padding: 0.25rem 0.6rem;
680
+ border: none;
681
+ background: #fff;
682
+ color: var(--muted);
683
+ cursor: pointer;
684
+ transition: background 0.15s, color 0.15s;
685
+ }
686
+
687
+ .sort-toggle button + button {
688
+ border-left: 1px solid var(--border);
689
+ }
690
+
691
+ .sort-toggle button.active {
692
+ background: var(--bar-fill);
693
+ color: #fff;
694
+ }
695
+
696
+ .sort-toggle button.active-async {
697
+ background: var(--bar-fill-async);
698
+ color: #fff;
699
+ }
700
+
454
701
  @media (max-width: 600px) {
455
702
  body { padding: 1rem; }
456
703
  .bar-cell { width: 25%; }
704
+ .sort-control { margin-left: 0; margin-top: 0.5rem; }
457
705
  }
458
706
  `;
459
707
  }
460
- function renderSummaryTable(packages, otherCount, totalTimeUs) {
708
+ function generateJs() {
709
+ return `
710
+ (function() {
711
+ var DATA = window.__REPORT_DATA__;
712
+ if (!DATA) return;
713
+ var HAS_ASYNC = !!(DATA.totalAsyncTimeUs && DATA.totalAsyncTimeUs > 0);
714
+
715
+ function formatTime(us) {
716
+ if (us === 0) return '0ms';
717
+ var ms = us / 1000;
718
+ if (ms >= 1000) return (ms / 1000).toFixed(2) + 's';
719
+ var rounded = Math.round(ms);
720
+ return (rounded < 1 ? 1 : rounded) + 'ms';
721
+ }
722
+
723
+ function formatPct(us, totalUs) {
724
+ if (totalUs === 0) return '0.0%';
725
+ return ((us / totalUs) * 100).toFixed(1) + '%';
726
+ }
727
+
728
+ function escapeHtml(str) {
729
+ return str
730
+ .replace(/&/g, '&amp;')
731
+ .replace(/</g, '&lt;')
732
+ .replace(/>/g, '&gt;')
733
+ .replace(/"/g, '&quot;')
734
+ .replace(/'/g, '&#39;');
735
+ }
736
+
737
+ var sortBy = 'cpu';
738
+
739
+ function metricTime(entry) {
740
+ return sortBy === 'async' ? (entry.asyncTimeUs || 0) : entry.timeUs;
741
+ }
742
+
743
+ function sortDesc(arr) {
744
+ return arr.slice().sort(function(a, b) { return metricTime(b) - metricTime(a); });
745
+ }
746
+
747
+ function applyThreshold(data, pct) {
748
+ var totalBase = sortBy === 'async' ? (data.totalAsyncTimeUs || 0) : data.totalTimeUs;
749
+ var threshold = totalBase * (pct / 100);
750
+ var filtered = [];
751
+ var otherCount = 0;
752
+
753
+ var pkgs = sortDesc(data.packages);
754
+
755
+ for (var i = 0; i < pkgs.length; i++) {
756
+ var pkg = pkgs[i];
757
+ if (metricTime(pkg) < threshold) {
758
+ otherCount++;
759
+ continue;
760
+ }
761
+
762
+ var files = [];
763
+ var fileOtherCount = 0;
764
+
765
+ var sortedFiles = sortDesc(pkg.files);
766
+
767
+ for (var j = 0; j < sortedFiles.length; j++) {
768
+ var file = sortedFiles[j];
769
+ if (metricTime(file) < threshold) {
770
+ fileOtherCount++;
771
+ continue;
772
+ }
773
+
774
+ var functions = [];
775
+ var funcOtherCount = 0;
776
+
777
+ var sortedFns = sortDesc(file.functions);
778
+
779
+ for (var k = 0; k < sortedFns.length; k++) {
780
+ var fn = sortedFns[k];
781
+ if (metricTime(fn) < threshold) {
782
+ funcOtherCount++;
783
+ continue;
784
+ }
785
+ functions.push(fn);
786
+ }
787
+
788
+ files.push({
789
+ name: file.name,
790
+ timeUs: file.timeUs,
791
+ pct: file.pct,
792
+ sampleCount: file.sampleCount,
793
+ asyncTimeUs: file.asyncTimeUs,
794
+ asyncPct: file.asyncPct,
795
+ asyncOpCount: file.asyncOpCount,
796
+ functions: functions,
797
+ otherCount: funcOtherCount
798
+ });
799
+ }
800
+
801
+ filtered.push({
802
+ name: pkg.name,
803
+ timeUs: pkg.timeUs,
804
+ pct: pkg.pct,
805
+ isFirstParty: pkg.isFirstParty,
806
+ sampleCount: pkg.sampleCount,
807
+ asyncTimeUs: pkg.asyncTimeUs,
808
+ asyncPct: pkg.asyncPct,
809
+ asyncOpCount: pkg.asyncOpCount,
810
+ files: files,
811
+ otherCount: fileOtherCount
812
+ });
813
+ }
814
+
815
+ return { packages: filtered, otherCount: otherCount };
816
+ }
817
+
818
+ function renderTable(packages, otherCount, totalTimeUs, totalAsyncTimeUs) {
819
+ var rows = '';
820
+ var isAsync = sortBy === 'async';
821
+ var barTotal = isAsync ? (totalAsyncTimeUs || 0) : totalTimeUs;
822
+ for (var i = 0; i < packages.length; i++) {
823
+ var pkg = packages[i];
824
+ var cls = pkg.isFirstParty ? 'first-party' : 'dependency';
825
+ var barVal = isAsync ? (pkg.asyncTimeUs || 0) : pkg.timeUs;
826
+ var pctVal = barTotal > 0 ? (barVal / barTotal) * 100 : 0;
827
+ rows += '<tr class="' + cls + '">' +
828
+ '<td class="pkg-name">' + escapeHtml(pkg.name) + '</td>' +
829
+ '<td class="numeric">' + escapeHtml(formatTime(pkg.timeUs)) + '</td>' +
830
+ '<td class="bar-cell"><div class="bar-container">' +
831
+ '<div class="bar-track"><div class="bar-fill" style="width:' + pctVal.toFixed(1) + '%"></div></div>' +
832
+ '<span class="bar-pct">' + escapeHtml(formatPct(barVal, barTotal)) + '</span>' +
833
+ '</div></td>' +
834
+ '<td class="numeric">' + pkg.sampleCount + '</td>';
835
+ if (HAS_ASYNC) {
836
+ rows += '<td class="numeric async-col">' + escapeHtml(formatTime(pkg.asyncTimeUs || 0)) + '</td>' +
837
+ '<td class="numeric async-col">' + (pkg.asyncOpCount || 0) + '</td>';
838
+ }
839
+ rows += '</tr>';
840
+ }
841
+
842
+ if (otherCount > 0) {
843
+ rows += '<tr class="other-row">' +
844
+ '<td class="pkg-name">Other (' + otherCount + ' items)</td>' +
845
+ '<td class="numeric"></td>' +
846
+ '<td class="bar-cell"></td>' +
847
+ '<td class="numeric"></td>';
848
+ if (HAS_ASYNC) {
849
+ rows += '<td class="numeric"></td><td class="numeric"></td>';
850
+ }
851
+ rows += '</tr>';
852
+ }
853
+
854
+ var headers = '<th>Package</th><th>CPU Time</th><th>% of Total</th><th>Samples</th>';
855
+ if (HAS_ASYNC) {
856
+ headers += '<th>Async I/O Wait</th><th>Async Ops</th>';
857
+ }
858
+
859
+ return '<table><thead><tr>' + headers + '</tr></thead><tbody>' + rows + '</tbody></table>';
860
+ }
861
+
862
+ function asyncStats(entry) {
863
+ if (!HAS_ASYNC) return '';
864
+ var at = entry.asyncTimeUs || 0;
865
+ var ac = entry.asyncOpCount || 0;
866
+ if (at === 0 && ac === 0) return '';
867
+ return ' <span class="tree-async">| ' + escapeHtml(formatTime(at)) + ' async &middot; ' + ac + ' ops</span>';
868
+ }
869
+
870
+ function renderTree(packages, otherCount, totalTimeUs, totalAsyncTimeUs) {
871
+ var html = '<div class="tree">';
872
+ var isAsync = sortBy === 'async';
873
+ var pctTotal = isAsync ? (totalAsyncTimeUs || 0) : totalTimeUs;
874
+
875
+ for (var i = 0; i < packages.length; i++) {
876
+ var pkg = packages[i];
877
+ var fpCls = pkg.isFirstParty ? ' fp-pkg' : '';
878
+ var pkgTime = isAsync ? (pkg.asyncTimeUs || 0) : pkg.timeUs;
879
+ html += '<details class="level-0' + fpCls + '"><summary>';
880
+ html += '<span class="tree-label pkg">pkg</span>';
881
+ html += '<span class="tree-name">' + escapeHtml(pkg.name) + '</span>';
882
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(pkgTime)) + ' &middot; ' + escapeHtml(formatPct(pkgTime, pctTotal)) + ' &middot; ' + pkg.sampleCount + ' samples</span>';
883
+ html += asyncStats(pkg);
884
+ html += '</summary>';
885
+
886
+ for (var j = 0; j < pkg.files.length; j++) {
887
+ var file = pkg.files[j];
888
+ var fileTime = isAsync ? (file.asyncTimeUs || 0) : file.timeUs;
889
+ html += '<details class="level-1"><summary>';
890
+ html += '<span class="tree-label file">file</span>';
891
+ html += '<span class="tree-name">' + escapeHtml(file.name) + '</span>';
892
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(fileTime)) + ' &middot; ' + escapeHtml(formatPct(fileTime, pctTotal)) + ' &middot; ' + file.sampleCount + ' samples</span>';
893
+ html += asyncStats(file);
894
+ html += '</summary>';
895
+
896
+ for (var k = 0; k < file.functions.length; k++) {
897
+ var fn = file.functions[k];
898
+ var fnTime = isAsync ? (fn.asyncTimeUs || 0) : fn.timeUs;
899
+ html += '<div class="level-2">';
900
+ html += '<span class="tree-label fn">fn</span> ';
901
+ html += '<span class="tree-name">' + escapeHtml(fn.name) + '</span>';
902
+ html += ' <span class="tree-stats">' + escapeHtml(formatTime(fnTime)) + ' &middot; ' + escapeHtml(formatPct(fnTime, pctTotal)) + ' &middot; ' + fn.sampleCount + ' samples</span>';
903
+ html += asyncStats(fn);
904
+ html += '</div>';
905
+ }
906
+
907
+ if (file.otherCount > 0) {
908
+ html += '<div class="other-item indent-2">Other (' + file.otherCount + ' items)</div>';
909
+ }
910
+
911
+ html += '</details>';
912
+ }
913
+
914
+ if (pkg.otherCount > 0) {
915
+ html += '<div class="other-item indent-1">Other (' + pkg.otherCount + ' items)</div>';
916
+ }
917
+
918
+ html += '</details>';
919
+ }
920
+
921
+ if (otherCount > 0) {
922
+ html += '<div class="other-item">Other (' + otherCount + ' packages)</div>';
923
+ }
924
+
925
+ html += '</div>';
926
+ return html;
927
+ }
928
+
929
+ var currentThreshold = 5;
930
+
931
+ function update(pct) {
932
+ currentThreshold = pct;
933
+ var result = applyThreshold(DATA, pct);
934
+ var summaryEl = document.getElementById('summary-container');
935
+ var treeEl = document.getElementById('tree-container');
936
+ if (summaryEl) summaryEl.innerHTML = renderTable(result.packages, result.otherCount, DATA.totalTimeUs, DATA.totalAsyncTimeUs);
937
+ if (treeEl) treeEl.innerHTML = renderTree(result.packages, result.otherCount, DATA.totalTimeUs, DATA.totalAsyncTimeUs);
938
+ }
939
+
940
+ function updateSortButtons() {
941
+ var btns = document.querySelectorAll('.sort-toggle button');
942
+ for (var i = 0; i < btns.length; i++) {
943
+ var btn = btns[i];
944
+ btn.className = '';
945
+ if (btn.getAttribute('data-sort') === sortBy) {
946
+ btn.className = sortBy === 'async' ? 'active-async' : 'active';
947
+ }
948
+ }
949
+ }
950
+
951
+ document.addEventListener('DOMContentLoaded', function() {
952
+ update(5);
953
+ var slider = document.getElementById('threshold-slider');
954
+ var label = document.getElementById('threshold-value');
955
+ if (slider) {
956
+ slider.addEventListener('input', function() {
957
+ var val = parseFloat(slider.value);
958
+ if (label) label.textContent = val.toFixed(1) + '%';
959
+ update(val);
960
+ });
961
+ }
962
+
963
+ var sortBtns = document.querySelectorAll('.sort-toggle button');
964
+ for (var i = 0; i < sortBtns.length; i++) {
965
+ sortBtns[i].addEventListener('click', function() {
966
+ sortBy = this.getAttribute('data-sort') || 'cpu';
967
+ updateSortButtons();
968
+ update(currentThreshold);
969
+ });
970
+ }
971
+ });
972
+ })();
973
+ `;
974
+ }
975
+ function renderSummaryTable(packages, otherCount, totalTimeUs, hasAsync) {
461
976
  let rows = "";
462
977
  for (const pkg of packages) {
463
978
  const cls = pkg.isFirstParty ? "first-party" : "dependency";
@@ -472,7 +987,9 @@ function renderSummaryTable(packages, otherCount, totalTimeUs) {
472
987
  <span class="bar-pct">${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))}</span>
473
988
  </div>
474
989
  </td>
475
- <td class="numeric">${pkg.sampleCount}</td>
990
+ <td class="numeric">${pkg.sampleCount}</td>${hasAsync ? `
991
+ <td class="numeric async-col">${escapeHtml(formatTime(pkg.asyncTimeUs ?? 0))}</td>
992
+ <td class="numeric async-col">${pkg.asyncOpCount ?? 0}</td>` : ""}
476
993
  </tr>`;
477
994
  }
478
995
  if (otherCount > 0) rows += `
@@ -480,23 +997,33 @@ function renderSummaryTable(packages, otherCount, totalTimeUs) {
480
997
  <td class="pkg-name">Other (${otherCount} items)</td>
481
998
  <td class="numeric"></td>
482
999
  <td class="bar-cell"></td>
1000
+ <td class="numeric"></td>${hasAsync ? `
483
1001
  <td class="numeric"></td>
1002
+ <td class="numeric"></td>` : ""}
484
1003
  </tr>`;
485
1004
  return `
486
1005
  <table>
487
1006
  <thead>
488
1007
  <tr>
489
1008
  <th>Package</th>
490
- <th>Wall Time</th>
1009
+ <th>CPU Time</th>
491
1010
  <th>% of Total</th>
492
- <th>Samples</th>
1011
+ <th>Samples</th>${hasAsync ? `
1012
+ <th>Async I/O Wait</th>
1013
+ <th>Async Ops</th>` : ""}
493
1014
  </tr>
494
1015
  </thead>
495
1016
  <tbody>${rows}
496
1017
  </tbody>
497
1018
  </table>`;
498
1019
  }
499
- function renderTree(packages, otherCount, totalTimeUs) {
1020
+ function formatAsyncStats(entry) {
1021
+ const at = entry.asyncTimeUs ?? 0;
1022
+ const ac = entry.asyncOpCount ?? 0;
1023
+ if (at === 0 && ac === 0) return "";
1024
+ return ` <span class="tree-async">| ${escapeHtml(formatTime(at))} async &middot; ${ac} ops</span>`;
1025
+ }
1026
+ function renderTree(packages, otherCount, totalTimeUs, hasAsync) {
500
1027
  let html = "<div class=\"tree\">";
501
1028
  for (const pkg of packages) {
502
1029
  const fpCls = pkg.isFirstParty ? " fp-pkg" : "";
@@ -505,6 +1032,7 @@ function renderTree(packages, otherCount, totalTimeUs) {
505
1032
  html += `<span class="tree-label pkg">pkg</span>`;
506
1033
  html += `<span class="tree-name">${escapeHtml(pkg.name)}</span>`;
507
1034
  html += `<span class="tree-stats">${escapeHtml(formatTime(pkg.timeUs))} &middot; ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} &middot; ${pkg.sampleCount} samples</span>`;
1035
+ if (hasAsync) html += formatAsyncStats(pkg);
508
1036
  html += `</summary>`;
509
1037
  for (const file of pkg.files) {
510
1038
  html += `<details class="level-1">`;
@@ -512,12 +1040,14 @@ function renderTree(packages, otherCount, totalTimeUs) {
512
1040
  html += `<span class="tree-label file">file</span>`;
513
1041
  html += `<span class="tree-name">${escapeHtml(file.name)}</span>`;
514
1042
  html += `<span class="tree-stats">${escapeHtml(formatTime(file.timeUs))} &middot; ${escapeHtml(formatPct(file.timeUs, totalTimeUs))} &middot; ${file.sampleCount} samples</span>`;
1043
+ if (hasAsync) html += formatAsyncStats(file);
515
1044
  html += `</summary>`;
516
1045
  for (const fn of file.functions) {
517
1046
  html += `<div class="level-2">`;
518
1047
  html += `<span class="tree-label fn">fn</span> `;
519
1048
  html += `<span class="tree-name">${escapeHtml(fn.name)}</span>`;
520
1049
  html += ` <span class="tree-stats">${escapeHtml(formatTime(fn.timeUs))} &middot; ${escapeHtml(formatPct(fn.timeUs, totalTimeUs))} &middot; ${fn.sampleCount} samples</span>`;
1050
+ if (hasAsync) html += formatAsyncStats(fn);
521
1051
  html += `</div>`;
522
1052
  }
523
1053
  if (file.otherCount > 0) html += `<div class="other-item indent-2">Other (${file.otherCount} items)</div>`;
@@ -532,12 +1062,22 @@ function renderTree(packages, otherCount, totalTimeUs) {
532
1062
  }
533
1063
  /**
534
1064
  * Render a complete self-contained HTML report from aggregated profiling data.
1065
+ *
1066
+ * @param data - Aggregated report data (packages, timing, project name).
1067
+ * @returns A full HTML document string with inline CSS/JS and no external dependencies.
535
1068
  */
536
1069
  function renderHtml(data) {
537
- const summaryTable = renderSummaryTable(data.packages, data.otherCount, data.totalTimeUs);
538
- const tree = renderTree(data.packages, data.otherCount, data.totalTimeUs);
1070
+ const hasAsync = !!(data.totalAsyncTimeUs && data.totalAsyncTimeUs > 0);
1071
+ const summaryTable = renderSummaryTable(data.packages, data.otherCount, data.totalTimeUs, hasAsync);
1072
+ const tree = renderTree(data.packages, data.otherCount, data.totalTimeUs, hasAsync);
539
1073
  const totalFormatted = escapeHtml(formatTime(data.totalTimeUs));
540
1074
  const titleName = escapeHtml(data.projectName);
1075
+ const wallFormatted = data.wallTimeUs ? escapeHtml(formatTime(data.wallTimeUs)) : null;
1076
+ let metaLine = `Generated ${escapeHtml(data.timestamp)}`;
1077
+ if (wallFormatted) metaLine += ` &middot; Wall time: ${wallFormatted}`;
1078
+ metaLine += ` &middot; CPU time: ${totalFormatted}`;
1079
+ if (hasAsync) metaLine += ` &middot; Async I/O wait: ${escapeHtml(formatTime(data.totalAsyncTimeUs))}`;
1080
+ const safeJson = JSON.stringify(data).replace(/</g, "\\u003c");
541
1081
  return `<!DOCTYPE html>
542
1082
  <html lang="en">
543
1083
  <head>
@@ -549,46 +1089,226 @@ function renderHtml(data) {
549
1089
  </head>
550
1090
  <body>
551
1091
  <h1>${titleName}</h1>
552
- <div class="meta">Generated ${escapeHtml(data.timestamp)} &middot; Total wall time: ${totalFormatted}</div>
1092
+ <div class="meta">${metaLine}</div>
553
1093
 
554
1094
  <h2>Summary</h2>
555
- ${summaryTable}
1095
+ <div class="threshold-control">
1096
+ <label>Threshold</label>
1097
+ <input type="range" id="threshold-slider" min="0" max="20" step="0.5" value="5">
1098
+ <span id="threshold-value">5.0%</span>${hasAsync ? `
1099
+ <span class="sort-control">
1100
+ <label>Sort by</label>
1101
+ <span class="sort-toggle">
1102
+ <button data-sort="cpu" class="active">CPU Time</button>
1103
+ <button data-sort="async">Async I/O Wait</button>
1104
+ </span>
1105
+ </span>` : ""}
1106
+ </div>
1107
+ <div id="summary-container">${summaryTable}</div>
556
1108
 
557
1109
  <h2>Details</h2>
558
- ${tree}
1110
+ <div id="tree-container">${tree}</div>
1111
+
1112
+ <script>var __REPORT_DATA__ = ${safeJson};<\/script>
1113
+ <script>${generateJs()}<\/script>
559
1114
  </body>
560
1115
  </html>`;
561
1116
  }
562
1117
 
563
1118
  //#endregion
564
- //#region src/reporter.ts
1119
+ //#region src/pkg-profile.ts
565
1120
  /**
566
- * Reporter orchestrator.
1121
+ * Immutable profiling result returned by `stop()` and `profile()`.
567
1122
  *
568
- * Aggregates SampleStore data, renders HTML, writes file to cwd,
569
- * and returns the file path.
1123
+ * Contains aggregated per-package timing data and a convenience method
1124
+ * to write a self-contained HTML report to disk.
570
1125
  */
571
- function generateFilename() {
1126
+ function generateFilename(timestamp) {
572
1127
  const now = /* @__PURE__ */ new Date();
573
1128
  const pad = (n) => String(n).padStart(2, "0");
574
1129
  return `where-you-at-${`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`}-${`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`}.html`;
575
1130
  }
576
- function readProjectName(cwd) {
577
- try {
578
- const raw = readFileSync(join(cwd, "package.json"), "utf-8");
579
- return JSON.parse(raw).name ?? "app";
580
- } catch {
581
- return "app";
1131
+ var PkgProfile = class {
1132
+ /** When the profile was captured */
1133
+ timestamp;
1134
+ /** Total sampled wall time in microseconds */
1135
+ totalTimeUs;
1136
+ /** Package breakdown sorted by time descending (all packages, no threshold applied) */
1137
+ packages;
1138
+ /** Always 0 — threshold filtering is now applied client-side in the HTML report */
1139
+ otherCount;
1140
+ /** Project name (from package.json) */
1141
+ projectName;
1142
+ /** Total async wait time in microseconds (undefined when async tracking not enabled) */
1143
+ totalAsyncTimeUs;
1144
+ /** Elapsed wall time in microseconds from start() to stop() */
1145
+ wallTimeUs;
1146
+ /** @internal */
1147
+ constructor(data) {
1148
+ this.timestamp = data.timestamp;
1149
+ this.totalTimeUs = data.totalTimeUs;
1150
+ this.packages = data.packages;
1151
+ this.otherCount = data.otherCount;
1152
+ this.projectName = data.projectName;
1153
+ this.totalAsyncTimeUs = data.totalAsyncTimeUs;
1154
+ this.wallTimeUs = data.wallTimeUs;
1155
+ }
1156
+ /**
1157
+ * Write a self-contained HTML report to disk.
1158
+ *
1159
+ * @param path - Output file path. Defaults to `./where-you-at-{timestamp}.html` in cwd.
1160
+ * @returns Absolute path to the written file.
1161
+ */
1162
+ writeHtml(path) {
1163
+ const html = renderHtml({
1164
+ timestamp: this.timestamp,
1165
+ totalTimeUs: this.totalTimeUs,
1166
+ packages: this.packages,
1167
+ otherCount: this.otherCount,
1168
+ projectName: this.projectName,
1169
+ totalAsyncTimeUs: this.totalAsyncTimeUs,
1170
+ wallTimeUs: this.wallTimeUs
1171
+ });
1172
+ let filepath;
1173
+ if (path) filepath = resolve(path);
1174
+ else {
1175
+ const filename = generateFilename(this.timestamp);
1176
+ filepath = join(process.cwd(), filename);
1177
+ }
1178
+ writeFileSync(filepath, html, "utf-8");
1179
+ return filepath;
582
1180
  }
1181
+ };
1182
+
1183
+ //#endregion
1184
+ //#region src/reporter/aggregate.ts
1185
+ /**
1186
+ * Sum all microseconds in a SampleStore.
1187
+ */
1188
+ function sumStore(store) {
1189
+ let total = 0;
1190
+ for (const fileMap of store.packages.values()) for (const funcMap of fileMap.values()) for (const us of funcMap.values()) total += us;
1191
+ return total;
583
1192
  }
584
- function generateReport(store, cwd) {
585
- const resolvedCwd = cwd ?? process.cwd();
586
- const html = renderHtml(aggregate(store, readProjectName(resolvedCwd)));
587
- const filename = generateFilename();
588
- const filepath = join(resolvedCwd, filename);
589
- writeFileSync(filepath, html, "utf-8");
590
- console.log(`Report written to ./${filename}`);
591
- return filepath;
1193
+ /**
1194
+ * Aggregate SampleStore data into a ReportData structure.
1195
+ *
1196
+ * @param store - SampleStore with accumulated microseconds and sample counts
1197
+ * @param projectName - Name of the first-party project (for isFirstParty flag)
1198
+ * @param asyncStore - Optional SampleStore with async wait time data
1199
+ * @returns ReportData with all packages sorted desc by time, no threshold applied
1200
+ */
1201
+ function aggregate(store, projectName, asyncStore, globalAsyncTimeUs, wallTimeUs) {
1202
+ const totalTimeUs = sumStore(store);
1203
+ const totalAsyncTimeUs = asyncStore ? sumStore(asyncStore) : 0;
1204
+ const headerAsyncTimeUs = globalAsyncTimeUs ?? totalAsyncTimeUs;
1205
+ if (totalTimeUs === 0 && totalAsyncTimeUs === 0) return {
1206
+ timestamp: (/* @__PURE__ */ new Date()).toLocaleString(),
1207
+ totalTimeUs: 0,
1208
+ packages: [],
1209
+ otherCount: 0,
1210
+ projectName
1211
+ };
1212
+ const allPackageNames = /* @__PURE__ */ new Set();
1213
+ for (const name of store.packages.keys()) allPackageNames.add(name);
1214
+ if (asyncStore) for (const name of asyncStore.packages.keys()) allPackageNames.add(name);
1215
+ const packages = [];
1216
+ for (const packageName of allPackageNames) {
1217
+ const fileMap = store.packages.get(packageName);
1218
+ let packageTimeUs = 0;
1219
+ if (fileMap) for (const funcMap of fileMap.values()) for (const us of funcMap.values()) packageTimeUs += us;
1220
+ let packageSampleCount = 0;
1221
+ const countFileMap = store.sampleCountsByPackage.get(packageName);
1222
+ if (countFileMap) for (const countFuncMap of countFileMap.values()) for (const count of countFuncMap.values()) packageSampleCount += count;
1223
+ let packageAsyncTimeUs = 0;
1224
+ let packageAsyncOpCount = 0;
1225
+ const asyncFileMap = asyncStore?.packages.get(packageName);
1226
+ const asyncCountFileMap = asyncStore?.sampleCountsByPackage.get(packageName);
1227
+ if (asyncFileMap) for (const funcMap of asyncFileMap.values()) for (const us of funcMap.values()) packageAsyncTimeUs += us;
1228
+ if (asyncCountFileMap) for (const countFuncMap of asyncCountFileMap.values()) for (const count of countFuncMap.values()) packageAsyncOpCount += count;
1229
+ const allFileNames = /* @__PURE__ */ new Set();
1230
+ if (fileMap) for (const name of fileMap.keys()) allFileNames.add(name);
1231
+ if (asyncFileMap) for (const name of asyncFileMap.keys()) allFileNames.add(name);
1232
+ const files = [];
1233
+ for (const fileName of allFileNames) {
1234
+ const funcMap = fileMap?.get(fileName);
1235
+ let fileTimeUs = 0;
1236
+ if (funcMap) for (const us of funcMap.values()) fileTimeUs += us;
1237
+ let fileSampleCount = 0;
1238
+ const countFuncMap = countFileMap?.get(fileName);
1239
+ if (countFuncMap) for (const count of countFuncMap.values()) fileSampleCount += count;
1240
+ let fileAsyncTimeUs = 0;
1241
+ let fileAsyncOpCount = 0;
1242
+ const asyncFuncMap = asyncFileMap?.get(fileName);
1243
+ const asyncCountFuncMap = asyncCountFileMap?.get(fileName);
1244
+ if (asyncFuncMap) for (const us of asyncFuncMap.values()) fileAsyncTimeUs += us;
1245
+ if (asyncCountFuncMap) for (const count of asyncCountFuncMap.values()) fileAsyncOpCount += count;
1246
+ const allFuncNames = /* @__PURE__ */ new Set();
1247
+ if (funcMap) for (const name of funcMap.keys()) allFuncNames.add(name);
1248
+ if (asyncFuncMap) for (const name of asyncFuncMap.keys()) allFuncNames.add(name);
1249
+ const functions = [];
1250
+ for (const funcName of allFuncNames) {
1251
+ const funcTimeUs = funcMap?.get(funcName) ?? 0;
1252
+ const funcSampleCount = countFuncMap?.get(funcName) ?? 0;
1253
+ const funcAsyncTimeUs = asyncFuncMap?.get(funcName) ?? 0;
1254
+ const funcAsyncOpCount = asyncCountFuncMap?.get(funcName) ?? 0;
1255
+ const entry = {
1256
+ name: funcName,
1257
+ timeUs: funcTimeUs,
1258
+ pct: totalTimeUs > 0 ? funcTimeUs / totalTimeUs * 100 : 0,
1259
+ sampleCount: funcSampleCount
1260
+ };
1261
+ if (totalAsyncTimeUs > 0) {
1262
+ entry.asyncTimeUs = funcAsyncTimeUs;
1263
+ entry.asyncPct = funcAsyncTimeUs / totalAsyncTimeUs * 100;
1264
+ entry.asyncOpCount = funcAsyncOpCount;
1265
+ }
1266
+ functions.push(entry);
1267
+ }
1268
+ functions.sort((a, b) => b.timeUs - a.timeUs);
1269
+ const fileEntry = {
1270
+ name: fileName,
1271
+ timeUs: fileTimeUs,
1272
+ pct: totalTimeUs > 0 ? fileTimeUs / totalTimeUs * 100 : 0,
1273
+ sampleCount: fileSampleCount,
1274
+ functions,
1275
+ otherCount: 0
1276
+ };
1277
+ if (totalAsyncTimeUs > 0) {
1278
+ fileEntry.asyncTimeUs = fileAsyncTimeUs;
1279
+ fileEntry.asyncPct = fileAsyncTimeUs / totalAsyncTimeUs * 100;
1280
+ fileEntry.asyncOpCount = fileAsyncOpCount;
1281
+ }
1282
+ files.push(fileEntry);
1283
+ }
1284
+ files.sort((a, b) => b.timeUs - a.timeUs);
1285
+ const pkgEntry = {
1286
+ name: packageName,
1287
+ timeUs: packageTimeUs,
1288
+ pct: totalTimeUs > 0 ? packageTimeUs / totalTimeUs * 100 : 0,
1289
+ isFirstParty: packageName === projectName,
1290
+ sampleCount: packageSampleCount,
1291
+ files,
1292
+ otherCount: 0
1293
+ };
1294
+ if (totalAsyncTimeUs > 0) {
1295
+ pkgEntry.asyncTimeUs = packageAsyncTimeUs;
1296
+ pkgEntry.asyncPct = packageAsyncTimeUs / totalAsyncTimeUs * 100;
1297
+ pkgEntry.asyncOpCount = packageAsyncOpCount;
1298
+ }
1299
+ packages.push(pkgEntry);
1300
+ }
1301
+ packages.sort((a, b) => b.timeUs - a.timeUs);
1302
+ const result = {
1303
+ timestamp: (/* @__PURE__ */ new Date()).toLocaleString(),
1304
+ totalTimeUs,
1305
+ packages,
1306
+ otherCount: 0,
1307
+ projectName
1308
+ };
1309
+ if (headerAsyncTimeUs > 0) result.totalAsyncTimeUs = headerAsyncTimeUs;
1310
+ if (wallTimeUs !== void 0) result.wallTimeUs = wallTimeUs;
1311
+ return result;
592
1312
  }
593
1313
 
594
1314
  //#endregion
@@ -671,56 +1391,160 @@ var SampleStore = class {
671
1391
  //#region src/sampler.ts
672
1392
  let session = null;
673
1393
  let profiling = false;
1394
+ let startHrtime = null;
674
1395
  const store = new SampleStore();
1396
+ const asyncStore = new SampleStore();
675
1397
  const resolver = new PackageResolver(process.cwd());
1398
+ let asyncTracker = null;
1399
+ /**
1400
+ * Promisify session.post for the normal async API path.
1401
+ */
1402
+ function postAsync(method, params) {
1403
+ return new Promise((resolve, reject) => {
1404
+ const cb = (err, result) => {
1405
+ if (err) reject(err);
1406
+ else resolve(result);
1407
+ };
1408
+ if (params !== void 0) session.post(method, params, cb);
1409
+ else session.post(method, cb);
1410
+ });
1411
+ }
1412
+ /**
1413
+ * Synchronous session.post — works because the V8 inspector executes
1414
+ * callbacks synchronously for in-process sessions.
1415
+ */
1416
+ function postSync(method) {
1417
+ let result;
1418
+ let error = null;
1419
+ const cb = (err, params) => {
1420
+ error = err;
1421
+ result = params;
1422
+ };
1423
+ session.post(method, cb);
1424
+ if (error) throw error;
1425
+ return result;
1426
+ }
1427
+ function readProjectName(cwd) {
1428
+ try {
1429
+ const raw = readFileSync(join(cwd, "package.json"), "utf-8");
1430
+ return JSON.parse(raw).name ?? "app";
1431
+ } catch {
1432
+ return "app";
1433
+ }
1434
+ }
1435
+ function buildEmptyProfile() {
1436
+ const projectName = readProjectName(process.cwd());
1437
+ return new PkgProfile({
1438
+ timestamp: (/* @__PURE__ */ new Date()).toLocaleString(),
1439
+ totalTimeUs: 0,
1440
+ packages: [],
1441
+ otherCount: 0,
1442
+ projectName
1443
+ });
1444
+ }
1445
+ /**
1446
+ * Shared logic for stopping the profiler and building a PkgProfile.
1447
+ * Synchronous — safe to call from process `exit` handlers.
1448
+ */
1449
+ function stopSync() {
1450
+ if (!profiling || !session) return buildEmptyProfile();
1451
+ const elapsed = startHrtime ? process.hrtime(startHrtime) : null;
1452
+ const wallTimeUs = elapsed ? elapsed[0] * 1e6 + Math.round(elapsed[1] / 1e3) : void 0;
1453
+ startHrtime = null;
1454
+ const { profile } = postSync("Profiler.stop");
1455
+ postSync("Profiler.disable");
1456
+ profiling = false;
1457
+ let globalAsyncTimeUs;
1458
+ if (asyncTracker) {
1459
+ asyncTracker.disable();
1460
+ globalAsyncTimeUs = asyncTracker.mergedTotalUs;
1461
+ asyncTracker = null;
1462
+ }
1463
+ processProfile(profile);
1464
+ const data = aggregate(store, readProjectName(process.cwd()), asyncStore.packages.size > 0 ? asyncStore : void 0, globalAsyncTimeUs, wallTimeUs);
1465
+ store.clear();
1466
+ asyncStore.clear();
1467
+ return new PkgProfile(data);
1468
+ }
676
1469
  /**
677
1470
  * Start the V8 CPU profiler. If already profiling, this is a safe no-op.
1471
+ *
1472
+ * @param options - Optional configuration.
1473
+ * @param options.interval - Sampling interval in microseconds passed to V8 (defaults to 1000µs). Lower values = higher fidelity but more overhead.
1474
+ * @returns Resolves when the profiler is successfully started
678
1475
  */
679
- async function track(options) {
1476
+ async function start(options) {
680
1477
  if (profiling) return;
681
1478
  if (session === null) {
682
1479
  session = new Session();
683
1480
  session.connect();
684
1481
  }
685
- await session.post("Profiler.enable");
686
- if (options?.interval !== void 0) await session.post("Profiler.setSamplingInterval", { interval: options.interval });
687
- await session.post("Profiler.start");
1482
+ await postAsync("Profiler.enable");
1483
+ if (options?.interval !== void 0) await postAsync("Profiler.setSamplingInterval", { interval: options.interval });
1484
+ await postAsync("Profiler.start");
688
1485
  profiling = true;
1486
+ startHrtime = process.hrtime();
1487
+ if (options?.trackAsync) {
1488
+ asyncTracker = new AsyncTracker(resolver, asyncStore);
1489
+ asyncTracker.enable();
1490
+ }
1491
+ }
1492
+ /**
1493
+ * Stop the profiler, process collected samples, and return a PkgProfile
1494
+ * containing the aggregated data. Resets the store afterward.
1495
+ *
1496
+ * @returns A PkgProfile with the profiling results, or a PkgProfile with empty data if no samples were collected.
1497
+ */
1498
+ async function stop() {
1499
+ return stopSync();
689
1500
  }
690
1501
  /**
691
1502
  * Stop the profiler (if running) and reset all accumulated sample data.
692
1503
  */
693
1504
  async function clear() {
694
1505
  if (profiling && session) {
695
- await session.post("Profiler.stop");
696
- await session.post("Profiler.disable");
1506
+ postSync("Profiler.stop");
1507
+ postSync("Profiler.disable");
697
1508
  profiling = false;
698
1509
  }
1510
+ startHrtime = null;
699
1511
  store.clear();
1512
+ if (asyncTracker) {
1513
+ asyncTracker.disable();
1514
+ asyncTracker = null;
1515
+ }
1516
+ asyncStore.clear();
700
1517
  }
701
- /**
702
- * Stop the profiler, process collected samples through the data pipeline
703
- * (parseFrame -> PackageResolver -> SampleStore), generate an HTML report,
704
- * and return the file path. Resets the store after reporting (clean slate
705
- * for next cycle).
706
- *
707
- * Returns the absolute path to the generated HTML file, or empty string
708
- * if no samples were collected.
709
- */
710
- async function report() {
711
- if (!profiling || !session) {
712
- console.log("no samples collected");
713
- return "";
1518
+ async function profile(fnOrOptions) {
1519
+ if (typeof fnOrOptions === "function") {
1520
+ await start();
1521
+ try {
1522
+ await fnOrOptions();
1523
+ } finally {
1524
+ return stop();
1525
+ }
714
1526
  }
715
- const { profile } = await session.post("Profiler.stop");
716
- await session.post("Profiler.disable");
717
- profiling = false;
718
- processProfile(profile);
719
- let filepath = "";
720
- if (store.packages.size > 0) filepath = generateReport(store);
721
- else console.log("no samples collected");
722
- store.clear();
723
- return filepath;
1527
+ const { onExit, ...startOpts } = fnOrOptions;
1528
+ await start(startOpts);
1529
+ let handled = false;
1530
+ const handler = (signal) => {
1531
+ if (handled) return;
1532
+ handled = true;
1533
+ process.removeListener("SIGINT", onSignal);
1534
+ process.removeListener("SIGTERM", onSignal);
1535
+ process.removeListener("exit", onProcessExit);
1536
+ onExit(stopSync());
1537
+ if (signal) process.kill(process.pid, signal);
1538
+ };
1539
+ const onSignal = (signal) => {
1540
+ handler(signal);
1541
+ };
1542
+ const onProcessExit = () => {
1543
+ handler();
1544
+ };
1545
+ process.once("SIGINT", onSignal);
1546
+ process.once("SIGTERM", onSignal);
1547
+ process.once("exit", onProcessExit);
724
1548
  }
725
1549
  /**
726
1550
  * Process a V8 CPUProfile: walk each sample, parse the frame, resolve
@@ -748,5 +1572,5 @@ function processProfile(profile) {
748
1572
  }
749
1573
 
750
1574
  //#endregion
751
- export { clear, report, track };
1575
+ export { PkgProfile, clear, profile, stop as report, stop, start, start as track };
752
1576
  //# sourceMappingURL=index.js.map