@oh-my-pi/pi-utils 15.9.67 → 15.10.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.
@@ -31,6 +31,8 @@ export declare function info(message: string, context?: Record<string, unknown>)
31
31
  * @param context - The context to log.
32
32
  */
33
33
  export declare function debug(message: string, context?: Record<string, unknown>): void;
34
+ export declare function timingModeIncludes(option: "full" | "x"): boolean;
35
+ export declare function shouldExitAfterTimings(): boolean;
34
36
  /**
35
37
  * Print collected timings as an indented tree.
36
38
  * Each span shows wall duration; parents with children also show "(self)" for unattributed time.
@@ -39,16 +41,16 @@ export declare function debug(message: string, context?: Record<string, unknown>
39
41
  export declare function printTimings(): void;
40
42
  /**
41
43
  * Begin recording startup timings under a new root span.
42
- * Idempotent: a second call while already recording is a no-op so that side-effect
43
- * starters (see module-timer.ts) and explicit starters (main.ts) can coexist.
44
+ * Idempotent: a second call while already recording is a no-op, so an explicit
45
+ * starter (main.ts) and any future early starter can coexist.
44
46
  */
45
47
  export declare function startTiming(): void;
46
48
  /**
47
49
  * Record an externally-measured span as a leaf child of the active span (or root
48
- * when no span is active). Used by the module-load timing plugin to splice load
49
- * events into the tree retroactively.
50
+ * when no span is active). Used by {@link spliceModuleLoadBuffer} to fold
51
+ * preload-captured module windows into the tree.
50
52
  */
51
- export declare function recordModuleLoadSpan(path: string, start: number, durationMs: number): void;
53
+ export declare function recordModuleLoadSpan(path: string, start: number, durationMs: number, bodyMs?: number, imports?: string[]): void;
52
54
  /**
53
55
  * End timing window and clear buffers.
54
56
  */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared contract between the {@link module-timer} preload and {@link logger}'s
3
+ * timing tree. Kept in its own dependency-free module so the preload can import
4
+ * it without pulling in winston (via logger) and the logger can drain the buffer
5
+ * without importing the Bun-plugin preload.
6
+ */
7
+ export interface ModuleLoadEvent {
8
+ /** Absolute or Bun-resolved module path. */
9
+ path: string;
10
+ /** `performance.now()` timestamp captured at Bun `onLoad` entry. */
11
+ start: number;
12
+ /** Inclusive module window: `onLoad` entry → appended final marker. */
13
+ durationMs: number;
14
+ /** Own top-level body / TLA time: prepended body marker → appended final marker. */
15
+ bodyMs?: number;
16
+ /** Resolved static children imported by this module. */
17
+ imports: string[];
18
+ }
19
+ /** The append-only buffer the preload pushes into (created on first access). */
20
+ export declare function moduleLoadBuffer(): ModuleLoadEvent[];
21
+ /** Drain and return all buffered events, leaving the buffer empty. */
22
+ export declare function drainModuleLoadEvents(): ModuleLoadEvent[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "15.9.67",
4
+ "version": "15.10.1",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "15.9.67",
34
+ "@oh-my-pi/pi-natives": "15.10.1",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/logger.ts CHANGED
@@ -15,6 +15,7 @@ import { isPromise } from "node:util/types";
15
15
  import winston from "winston";
16
16
  import DailyRotateFile from "winston-daily-rotate-file";
17
17
  import { getLogsDir } from "./dirs";
18
+ import { drainModuleLoadEvents } from "./timing-buffer";
18
19
 
19
20
  /** Ensure a logs directory exists; return the resolved path. */
20
21
  function ensureDir(dir: string): string {
@@ -166,12 +167,36 @@ interface Span {
166
167
  children: Span[];
167
168
  /** Marker / point event without a duration. */
168
169
  point?: boolean;
170
+ /** Absolute module path for module-load spans. */
171
+ modulePath?: string;
172
+ /** Own top-level module body / TLA duration for module-load spans. */
173
+ moduleBodyMs?: number;
174
+ /** Resolved static imports for module-load spans. */
175
+ moduleImports?: string[];
169
176
  }
170
-
171
177
  const spanStorage = new AsyncLocalStorage<Span>();
172
178
  let gRootSpan: Span | undefined;
173
179
  let gRecordTimings = false;
174
180
 
181
+ export function timingModeIncludes(option: "full" | "x"): boolean {
182
+ const value = process.env.PI_TIMING;
183
+ if (!value) return false;
184
+ if (value === option) return true;
185
+ let start = 0;
186
+ for (let i = 0; i <= value.length; i++) {
187
+ const code = i === value.length ? 44 : value.charCodeAt(i);
188
+ const separator = code === 44 || code === 58 || code === 59 || code === 43 || code <= 32;
189
+ if (!separator) continue;
190
+ if (i > start && value.slice(start, i) === option) return true;
191
+ start = i + 1;
192
+ }
193
+ return false;
194
+ }
195
+
196
+ export function shouldExitAfterTimings(): boolean {
197
+ return timingModeIncludes("x") || timingModeIncludes("full");
198
+ }
199
+
175
200
  /**
176
201
  * Print collected timings as an indented tree.
177
202
  * Each span shows wall duration; parents with children also show "(self)" for unattributed time.
@@ -184,9 +209,22 @@ export function printTimings(): void {
184
209
  }
185
210
 
186
211
  gRootSpan.end = performance.now();
212
+ // Splice any preload-captured module-load events into the tree as root
213
+ // children and back-extend the root window over them, so the static-import
214
+ // phase that ran before the first explicit marker becomes visible (the
215
+ // `(modules)` summary below) instead of being lumped into the opaque
216
+ // `(before instrumentation)` figure.
217
+ spliceModuleLoadBuffer();
187
218
  const lines: string[] = [];
188
219
  lines.push("");
189
220
  lines.push("--- Startup timings (hierarchical) ---");
221
+ // performance.now() shares the process-start origin, so the root span's start
222
+ // is the wall time before the first marker — runtime init plus any module
223
+ // loads not captured below. With the module-load preload active this shrinks
224
+ // to ~runtime init because the load phase is back-folded into the window.
225
+ if (gRootSpan.start > LOGGED_TIMING_THRESHOLD_MS) {
226
+ lines.push(`(before instrumentation): ${fmtMs(gRootSpan.start)} [runtime init + module load]`);
227
+ }
190
228
  const work: Span[] = [];
191
229
  const loads: Span[] = [];
192
230
  for (const child of gRootSpan.children) {
@@ -199,8 +237,14 @@ export function printTimings(): void {
199
237
  if (loads.length > 0) {
200
238
  printModuleLoadSummary(loads, 0, lines);
201
239
  }
240
+ // Surface the root's own unattributed time so the gap between the visible
241
+ // top-level spans and Total isn't silently swallowed.
242
+ const rootSelf = selfTimeOf(gRootSpan);
243
+ if (gRootSpan.children.length > 0 && rootSelf > LOGGED_TIMING_THRESHOLD_MS) {
244
+ lines.push(`(unattributed self): ${fmtMs(rootSelf)}`);
245
+ }
202
246
  const totalMs = (gRootSpan.end - gRootSpan.start).toFixed(1);
203
- lines.push(`Total: ${totalMs}ms`);
247
+ lines.push(`Total: ${totalMs}ms (since first marker)`);
204
248
  lines.push("--------------------------------------");
205
249
  lines.push("");
206
250
  console.error(lines.join("\n"));
@@ -209,8 +253,8 @@ export function printTimings(): void {
209
253
 
210
254
  /**
211
255
  * Begin recording startup timings under a new root span.
212
- * Idempotent: a second call while already recording is a no-op so that side-effect
213
- * starters (see module-timer.ts) and explicit starters (main.ts) can coexist.
256
+ * Idempotent: a second call while already recording is a no-op, so an explicit
257
+ * starter (main.ts) and any future early starter can coexist.
214
258
  */
215
259
  export function startTiming(): void {
216
260
  if (gRecordTimings) return;
@@ -225,10 +269,16 @@ export function startTiming(): void {
225
269
 
226
270
  /**
227
271
  * Record an externally-measured span as a leaf child of the active span (or root
228
- * when no span is active). Used by the module-load timing plugin to splice load
229
- * events into the tree retroactively.
272
+ * when no span is active). Used by {@link spliceModuleLoadBuffer} to fold
273
+ * preload-captured module windows into the tree.
230
274
  */
231
- export function recordModuleLoadSpan(path: string, start: number, durationMs: number): void {
275
+ export function recordModuleLoadSpan(
276
+ path: string,
277
+ start: number,
278
+ durationMs: number,
279
+ bodyMs?: number,
280
+ imports: string[] = [],
281
+ ): void {
232
282
  if (!gRecordTimings || !gRootSpan) return;
233
283
  const parent = spanStorage.getStore() ?? gRootSpan;
234
284
  const span: Span = {
@@ -237,10 +287,32 @@ export function recordModuleLoadSpan(path: string, start: number, durationMs: nu
237
287
  end: start + durationMs,
238
288
  parent,
239
289
  children: [],
290
+ modulePath: path,
291
+ moduleBodyMs: bodyMs,
292
+ moduleImports: imports,
240
293
  };
241
294
  parent.children.push(span);
242
295
  }
243
296
 
297
+ /**
298
+ * Drain the preload's module-load buffer (see module-timer.ts) into the tree as
299
+ * `load:` children of the root, then back-extend the root window to the earliest
300
+ * captured read so the pre-marker load phase is counted in Total rather than
301
+ * hidden as `(before instrumentation)`. No-op when nothing was captured (e.g. no
302
+ * `--preload`, or a compiled binary where module reads are not interceptable).
303
+ */
304
+ function spliceModuleLoadBuffer(): void {
305
+ if (!gRootSpan) return;
306
+ const events = drainModuleLoadEvents();
307
+ if (events.length === 0) return;
308
+ let earliest = gRootSpan.start;
309
+ for (const event of events) {
310
+ recordModuleLoadSpan(event.path, event.start, event.durationMs, event.bodyMs, event.imports);
311
+ if (event.start < earliest) earliest = event.start;
312
+ }
313
+ gRootSpan.start = earliest;
314
+ }
315
+
244
316
  function shortenLoadPath(p: string): string {
245
317
  const cwd = process.cwd();
246
318
  if (p.startsWith(`${cwd}/`)) return p.slice(cwd.length + 1);
@@ -296,6 +368,16 @@ function fmtMs(ms: number): string {
296
368
 
297
369
  const MODULE_LOAD_PREFIX = "load:";
298
370
  const MODULE_LOAD_VERBOSE_TOP = 10;
371
+ const MODULE_TREE_MAX_DEPTH = 5;
372
+ const MODULE_TREE_ROOT_TOP = 5;
373
+ const MODULE_TREE_CHILD_TOP = 8;
374
+
375
+ interface ModuleTimingNode {
376
+ span: Span;
377
+ children: ModuleTimingNode[];
378
+ parents: number;
379
+ body: number;
380
+ }
299
381
 
300
382
  function isModuleLoadSpan(span: Span): boolean {
301
383
  return span.op.startsWith(MODULE_LOAD_PREFIX);
@@ -330,33 +412,118 @@ function printSpan(span: Span, depth: number, lines: string[]): void {
330
412
  }
331
413
  }
332
414
 
333
- /** Collapse the (typically hundreds of) module-load spans into one summary line. */
415
+ /** Render module-load spans as a dependency-aware DAG/tree. */
334
416
  function printModuleLoadSummary(loads: Span[], depth: number, lines: string[]): void {
335
417
  const childIndent = " ".repeat(depth);
336
418
  const grandIndent = " ".repeat(depth + 1);
337
419
  let unionStart = Number.POSITIVE_INFINITY;
338
420
  let unionEnd = 0;
339
- let totalSelf = 0;
340
421
  for (const span of loads) {
341
422
  if (span.end === undefined) continue;
342
423
  if (span.start < unionStart) unionStart = span.start;
343
424
  if (span.end > unionEnd) unionEnd = span.end;
344
- totalSelf += span.end - span.start;
345
425
  }
346
426
  const wall = unionEnd > unionStart ? unionEnd - unionStart : 0;
347
- lines.push(`${childIndent}(modules): ${loads.length} loaded, wall ${fmtMs(wall)}, sum ${fmtMs(totalSelf)}`);
348
- const showAll = process.env.PI_TIMING === "full";
349
- const sorted = [...loads].sort((a, b) => durationOf(b) - durationOf(a));
350
- const visible = showAll ? sorted : sorted.slice(0, MODULE_LOAD_VERBOSE_TOP);
351
- for (const span of visible) {
352
- const dur = durationOf(span);
353
- if (dur < LOGGED_TIMING_THRESHOLD_MS) break;
354
- const tag = isParallel(span) ? " [parallel]" : "";
355
- lines.push(`${grandIndent}${span.op}: ${fmtMs(dur)}${tag}`);
356
- }
357
- if (!showAll && sorted.length > MODULE_LOAD_VERBOSE_TOP) {
358
- lines.push(`${grandIndent}… ${sorted.length - MODULE_LOAD_VERBOSE_TOP} more (PI_TIMING=full to show all)`);
427
+ const nodes = buildModuleTimingGraph(loads);
428
+ lines.push(`${childIndent}(modules): ${loads.length} loaded, wall ${fmtMs(wall)}`);
429
+ if (nodes.length === 0) return;
430
+
431
+ const showAll = timingModeIncludes("full");
432
+ const byBody = [...nodes].sort(compareModuleNodes);
433
+ const topBody = showAll ? byBody : byBody.slice(0, MODULE_LOAD_VERBOSE_TOP);
434
+ lines.push(`${grandIndent}top body/TLA:`);
435
+ for (const node of topBody) {
436
+ if (!showAll && node.body < LOGGED_TIMING_THRESHOLD_MS) break;
437
+ lines.push(`${grandIndent} ${node.span.op}: body ${fmtMs(node.body)} (total ${fmtMs(durationOf(node.span))})`);
438
+ }
439
+ if (!showAll && byBody.length > MODULE_LOAD_VERBOSE_TOP) {
440
+ lines.push(`${grandIndent} … ${byBody.length - MODULE_LOAD_VERBOSE_TOP} more (PI_TIMING=full to show all)`);
441
+ }
442
+
443
+ const roots = nodes.filter(node => node.parents === 0);
444
+ const treeRoots = (roots.length > 0 ? roots : nodes).sort((a, b) => durationOf(b.span) - durationOf(a.span));
445
+ const visibleRoots = showAll ? treeRoots : treeRoots.slice(0, MODULE_TREE_ROOT_TOP);
446
+ lines.push(`${grandIndent}tree:`);
447
+ const rendered = new Set<string>();
448
+ for (const node of visibleRoots) {
449
+ renderModuleTimingNode(node, depth + 2, lines, rendered, new Set<string>(), showAll);
450
+ }
451
+ if (!showAll && treeRoots.length > MODULE_TREE_ROOT_TOP) {
452
+ lines.push(
453
+ `${grandIndent} … ${treeRoots.length - MODULE_TREE_ROOT_TOP} more roots (PI_TIMING=full to show all)`,
454
+ );
455
+ }
456
+ }
457
+
458
+ function buildModuleTimingGraph(loads: Span[]): ModuleTimingNode[] {
459
+ const nodes = new Map<string, ModuleTimingNode>();
460
+ for (const span of loads) {
461
+ if (!span.modulePath || span.end === undefined) continue;
462
+ nodes.set(span.modulePath, { span, children: [], parents: 0, body: span.moduleBodyMs ?? 0 });
463
+ }
464
+ for (const node of nodes.values()) {
465
+ for (const childPath of node.span.moduleImports ?? []) {
466
+ const child = nodes.get(childPath);
467
+ if (!child || child === node) continue;
468
+ node.children.push(child);
469
+ child.parents++;
470
+ }
471
+ }
472
+ for (const node of nodes.values()) {
473
+ node.children.sort(compareModuleNodes);
474
+ }
475
+ return [...nodes.values()];
476
+ }
477
+
478
+ function compareModuleNodes(a: ModuleTimingNode, b: ModuleTimingNode): number {
479
+ const bodyDiff = b.body - a.body;
480
+ if (Math.abs(bodyDiff) > 0.001) return bodyDiff;
481
+ return durationOf(b.span) - durationOf(a.span);
482
+ }
483
+
484
+ function renderModuleTimingNode(
485
+ node: ModuleTimingNode,
486
+ depth: number,
487
+ lines: string[],
488
+ rendered: Set<string>,
489
+ ancestors: Set<string>,
490
+ showAll: boolean,
491
+ ): void {
492
+ const path = node.span.modulePath;
493
+ if (!path) return;
494
+ const indent = " ".repeat(depth);
495
+ const total = durationOf(node.span);
496
+ if (!showAll && total < LOGGED_TIMING_THRESHOLD_MS && node.children.length === 0) return;
497
+ const wait = Math.max(0, total - node.body);
498
+ const shared = node.parents > 1 ? " [shared]" : "";
499
+ const timing =
500
+ node.body > LOGGED_TIMING_THRESHOLD_MS || node.children.length > 0
501
+ ? ` (body ${fmtMs(node.body)}, wait ${fmtMs(wait)})`
502
+ : "";
503
+ const alreadyRendered = rendered.has(path);
504
+ const cycle = ancestors.has(path);
505
+ const suffix = cycle ? " [cycle]" : alreadyRendered ? " [already shown]" : "";
506
+ lines.push(`${indent}${node.span.op}: ${fmtMs(total)}${timing}${shared}${suffix}`);
507
+ if (cycle || alreadyRendered) return;
508
+ rendered.add(path);
509
+ ancestors.add(path);
510
+ if (!showAll && ancestors.size >= MODULE_TREE_MAX_DEPTH) {
511
+ if (node.children.length > 0) {
512
+ lines.push(`${indent} … ${node.children.length} imports deeper (PI_TIMING=full to show all)`);
513
+ }
514
+ ancestors.delete(path);
515
+ return;
516
+ }
517
+ const visibleChildren = showAll ? node.children : node.children.slice(0, MODULE_TREE_CHILD_TOP);
518
+ for (const child of visibleChildren) {
519
+ renderModuleTimingNode(child, depth + 1, lines, rendered, ancestors, showAll);
520
+ }
521
+ if (!showAll && node.children.length > MODULE_TREE_CHILD_TOP) {
522
+ lines.push(
523
+ `${indent} … ${node.children.length - MODULE_TREE_CHILD_TOP} more imports (PI_TIMING=full to show all)`,
524
+ );
359
525
  }
526
+ ancestors.delete(path);
360
527
  }
361
528
 
362
529
  /** A span is parallel if it overlaps a sibling that started before it. */
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Module-load timing preload.
3
+ *
4
+ * `bun --preload .../module-timer.ts <entry>` installs Bun plugin hooks (only
5
+ * when `PI_TIMING` is set) that record an inclusive module window plus resolved
6
+ * static child edges:
7
+ *
8
+ * onLoad start → appended end marker after the module's top-level body
9
+ *
10
+ * Events are pushed into a process-global buffer that {@link logger.printTimings}
11
+ * drains and renders as a module DAG/tree. Each module row can therefore show
12
+ * both total time and `self` time after subtracting child module intervals.
13
+ *
14
+ * Why a preload (and not a normal import): Bun reads the *entire* statically
15
+ * reachable graph before evaluating any module, so hooks installed from inside
16
+ * that graph cannot observe its own loading — they only catch later dynamically
17
+ * loaded modules. A preload runs first, so it sees the static-import phase that
18
+ * dominates startup.
19
+ *
20
+ * Kept dependency-free on purpose: the sole import is Bun's `plugin`, so this is
21
+ * cheap to preload before pi-utils (and winston) exist. The buffer is shared with
22
+ * the logger via a registry Symbol so neither side needs to import the other.
23
+ *
24
+ * **What is measured:** an inclusive per-module window. `onLoad` stamps the
25
+ * start before reading source; the returned source has a tiny marker appended at
26
+ * the end of the module. That marker runs after Bun parses/transpiles the module
27
+ * and after any top-level await in that module completes, so the duration
28
+ * includes read + parse/transpile + dependency wait + top-level execution/TLA.
29
+ * If a module throws before its final statement, no end marker is recorded.
30
+ *
31
+ * **Tree shape:** `onResolve` observes importer → specifier edges and resolves
32
+ * them with `Bun.resolveSync` without taking over Bun's real resolution. The
33
+ * logger renders these edges as a DAG/tree and computes module `self` time by
34
+ * subtracting the union of child intervals, avoiding misleading flat inclusive
35
+ * totals.
36
+ *
37
+ * **Coverage limits:**
38
+ * - TS/TSX only — intercepting `node_modules` CJS `.js`/`.cjs` and forcing ESM
39
+ * breaks their default-export detection, so they are left to Bun's default path.
40
+ * - **Dev runs only.** In the compiled `omp` binary every module is pre-bundled
41
+ * into bunfs, so `onLoad` never fires; profile with a `bun --preload` dev run.
42
+ */
43
+ import { plugin } from "bun";
44
+ import { moduleLoadBuffer } from "./timing-buffer";
45
+
46
+ // Restrict to TS/TSX only. node_modules ships CommonJS `.js`/`.cjs` that Bun
47
+ // auto-detects when loaded via its default path; if we intercept and return
48
+ // `{ contents, loader: "js" }`, Bun forces ESM and CJS modules fail to load
49
+ // (e.g. `Missing 'default' export`). Our own source tree (where the interesting
50
+ // timing lives) is uniformly TypeScript, so a TS-only filter is both safe and
51
+ // sufficient.
52
+ const MODULE_LOADER_FILTER = /\.[mc]?tsx?$/;
53
+ const MODULE_COMPLETE_KEY: symbol = Symbol.for("omp.moduleLoadComplete");
54
+ const MODULE_BODY_START_KEY: symbol = Symbol.for("omp.moduleBodyStart");
55
+ const STATIC_IMPORT_PATTERN =
56
+ /\b(?:import|export)\s+(?:type\s+)?(?:[^"']*?\s+from\s+)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
57
+
58
+ type CompleteStore = Record<symbol, ((path: string) => void) | undefined>;
59
+
60
+ function bodyStartMarker(path: string): string {
61
+ return `;globalThis[Symbol.for("omp.moduleBodyStart")]?.(${JSON.stringify(path)});\n`;
62
+ }
63
+
64
+ function completionMarker(path: string): string {
65
+ return `\n;globalThis[Symbol.for("omp.moduleLoadComplete")]?.(${JSON.stringify(path)});\n`;
66
+ }
67
+
68
+ function instrumentContents(path: string, contents: string): string {
69
+ const start = bodyStartMarker(path);
70
+ const end = completionMarker(path);
71
+ if (!contents.startsWith("#!")) return `${start}${contents}${end}`;
72
+ const newline = contents.indexOf("\n");
73
+ if (newline === -1) return `${contents}\n${start}${end}`;
74
+ return `${contents.slice(0, newline + 1)}${start}${contents.slice(newline + 1)}${end}`;
75
+ }
76
+ function importerDir(importer: string): string {
77
+ const slash = importer.lastIndexOf("/");
78
+ if (slash === -1) return ".";
79
+ return importer.slice(0, slash);
80
+ }
81
+
82
+ function childSetFor(importsByPath: Map<string, Set<string>>, path: string): Set<string> {
83
+ let children = importsByPath.get(path);
84
+ if (!children) {
85
+ children = new Set<string>();
86
+ importsByPath.set(path, children);
87
+ }
88
+ return children;
89
+ }
90
+
91
+ function addImportEdges(importsByPath: Map<string, Set<string>>, importer: string, contents: string): void {
92
+ STATIC_IMPORT_PATTERN.lastIndex = 0;
93
+ for (const match of contents.matchAll(STATIC_IMPORT_PATTERN)) {
94
+ const specifier = match[1] ?? match[2];
95
+ if (!specifier) continue;
96
+ try {
97
+ const resolved = Bun.resolveSync(specifier, importerDir(importer));
98
+ if (MODULE_LOADER_FILTER.test(resolved) && resolved !== importer) {
99
+ childSetFor(importsByPath, importer).add(resolved);
100
+ }
101
+ } catch {
102
+ // Leave Bun's real resolver/runtime to surface any error. This scanner is only an observer.
103
+ }
104
+ }
105
+ }
106
+
107
+ if (process.env.PI_TIMING) {
108
+ const buffer = moduleLoadBuffer();
109
+ const starts = new Map<string, number>();
110
+ const bodyStarts = new Map<string, number>();
111
+ const importsByPath = new Map<string, Set<string>>();
112
+ const store = globalThis as unknown as CompleteStore;
113
+ store[MODULE_BODY_START_KEY] = (path: string): void => {
114
+ bodyStarts.set(path, performance.now());
115
+ };
116
+ store[MODULE_COMPLETE_KEY] = (path: string): void => {
117
+ const start = starts.get(path);
118
+ if (start === undefined) return;
119
+ starts.delete(path);
120
+ const end = performance.now();
121
+ const bodyStart = bodyStarts.get(path);
122
+ bodyStarts.delete(path);
123
+ const imports = importsByPath.get(path);
124
+ buffer.push({
125
+ path,
126
+ start,
127
+ durationMs: end - start,
128
+ bodyMs: bodyStart === undefined ? undefined : end - bodyStart,
129
+ imports: imports ? [...imports] : [],
130
+ });
131
+ };
132
+
133
+ plugin({
134
+ name: "pi-module-load-timer",
135
+ setup(build) {
136
+ build.onLoad({ filter: MODULE_LOADER_FILTER }, async args => {
137
+ starts.set(args.path, performance.now());
138
+ childSetFor(importsByPath, args.path);
139
+ const contents = await Bun.file(args.path).text();
140
+ addImportEdges(importsByPath, args.path, contents);
141
+ return {
142
+ contents: instrumentContents(args.path, contents),
143
+ loader: args.path.endsWith(".tsx") ? "tsx" : "ts",
144
+ };
145
+ });
146
+ },
147
+ });
148
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared contract between the {@link module-timer} preload and {@link logger}'s
3
+ * timing tree. Kept in its own dependency-free module so the preload can import
4
+ * it without pulling in winston (via logger) and the logger can drain the buffer
5
+ * without importing the Bun-plugin preload.
6
+ */
7
+
8
+ export interface ModuleLoadEvent {
9
+ /** Absolute or Bun-resolved module path. */
10
+ path: string;
11
+ /** `performance.now()` timestamp captured at Bun `onLoad` entry. */
12
+ start: number;
13
+ /** Inclusive module window: `onLoad` entry → appended final marker. */
14
+ durationMs: number;
15
+ /** Own top-level body / TLA time: prepended body marker → appended final marker. */
16
+ bodyMs?: number;
17
+ /** Resolved static children imported by this module. */
18
+ imports: string[];
19
+ }
20
+
21
+ /**
22
+ * Registry-global key under which the preload accumulates module-load events.
23
+ * `Symbol.for` so both modules resolve the same symbol independently.
24
+ */
25
+ const KEY: symbol = Symbol.for("omp.moduleLoadBuffer");
26
+
27
+ type Store = Record<symbol, ModuleLoadEvent[] | undefined>;
28
+
29
+ /** The append-only buffer the preload pushes into (created on first access). */
30
+ export function moduleLoadBuffer(): ModuleLoadEvent[] {
31
+ const store = globalThis as unknown as Store;
32
+ let buffer = store[KEY];
33
+ if (!buffer) {
34
+ buffer = [];
35
+ store[KEY] = buffer;
36
+ }
37
+ return buffer;
38
+ }
39
+
40
+ /** Drain and return all buffered events, leaving the buffer empty. */
41
+ export function drainModuleLoadEvents(): ModuleLoadEvent[] {
42
+ const store = globalThis as unknown as Store;
43
+ const buffer = store[KEY];
44
+ if (!buffer || buffer.length === 0) return [];
45
+ store[KEY] = [];
46
+ return buffer;
47
+ }