Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions lib/ProfilerPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
MIT License http://www.opensource.org/licenses/mit-license.php
Author jhonsnow456
*/

"use strict";

/** @typedef {import("./Resolver")} Resolver */
/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
/** @typedef {import("./Resolver").ResolveContext} ResolveContext */

/**
* @typedef {object} ProfileHookEntry
* @property {string} name hook name
* @property {number} count number of times this hook was entered
* @property {number} totalTime total wall-clock time in milliseconds
*/

/**
* @typedef {object} ProfileData
* @property {number} startTime when the resolve started (Date.now() epoch)
* @property {number} endTime when the resolve completed
* @property {number} duration total duration in milliseconds
* @property {string} specifier the request string that was resolved
* @property {string} parent the parent path resolution started from
* @property {boolean} success whether the resolution succeeded
* @property {string | null} result the resolved path, or null on failure
* @property {Map<string, ProfileHookEntry>} hooks per-hook profiling data
*/

const now = () => Date.now();

module.exports = class ProfilerPlugin {
/**
* @param {(profile: ProfileData) => void=} profileCallback optional callback
*/
constructor(profileCallback) {
this.profileCallback = profileCallback;
}

/**
* @param {Resolver} resolver the resolver
* @returns {void}
*/
apply(resolver) {
const { profileCallback } = this;
const { hooks } = resolver;
const resolveStepHook = hooks.resolveStep;
const resultHook = hooks.result;
const noResolveHook = hooks.noResolve;

/** @type {{ name: string, lastEventTime: number }[]} */
const frameStack = [];

/** @type {Map<string, ProfileHookEntry>} */
let hookStats = new Map();

/** @type {ProfileData | null} */
let currentProfile = null;

/**
* Pop all remaining frames and attribute remaining time.
* @param {number} endTime end timestamp
*/
function finalizeFrames(endTime) {
while (frameStack.length > 0) {
const frameEntry = frameStack.pop();
if (!frameEntry) break;
const elapsed = endTime - frameEntry.lastEventTime;
const stats = hookStats.get(frameEntry.name);
if (stats) {
stats.totalTime += elapsed;
}
}
}

/**
* Deliver profile data to the caller.
* @param {ResolveContext} resolveContext resolve context
* @param {ProfileData} profile profile data to deliver
*/
function emitProfile(resolveContext, profile) {
const callback =
typeof resolveContext.profile === "function"
? resolveContext.profile
: profileCallback;
if (typeof callback === "function") {
callback(profile);
}
if (resolveContext.log) {
resolveContext.log(
`profile: resolved "${profile.specifier}" in ${profile.duration.toFixed(2)}ms`,
);
for (const hookStatsEntry of profile.hooks.values()) {
resolveContext.log(
` ${hookStatsEntry.name}: ${hookStatsEntry.count}x, ${hookStatsEntry.totalTime.toFixed(2)}ms`,
);
}
}
}

resolveStepHook.tap("ProfilerPlugin", (hook, request) => {
const time = now();
const name = typeof hook === "string" ? hook : hook.name || "unknown";

if (frameStack.length === 0) {
currentProfile = {
startTime: time,
endTime: 0,
duration: 0,
specifier: request.request || "",
parent: request.path || "",
success: false,
result: null,
hooks: hookStats,
};
}

if (frameStack.length > 0) {
const parent = frameStack[frameStack.length - 1];
const elapsed = time - parent.lastEventTime;
const parentStats = hookStats.get(parent.name);
if (parentStats) {
parentStats.totalTime += elapsed;
}
}

let stats = hookStats.get(name);
if (!stats) {
stats = { name, count: 0, totalTime: 0 };
hookStats.set(name, stats);
}
stats.count++;

frameStack.push({ name, lastEventTime: time });
});

/**
* Finalize profiling when resolution completes successfully.
*/
resultHook.tapAsync("ProfilerPlugin", (request, ctx, callback) => {
if (currentProfile) {
const time = now();
currentProfile.endTime = time;
currentProfile.duration = time - currentProfile.startTime;
currentProfile.success = true;
currentProfile.result = request.path || null;

finalizeFrames(time);
emitProfile(ctx, currentProfile);
currentProfile = null;
hookStats = new Map();
}
callback(null);
});

/**
* Finalize profiling when resolution fails.
*/
noResolveHook.tap("ProfilerPlugin", (_request, _error) => {
if (currentProfile) {
const time = now();
currentProfile.endTime = time;
currentProfile.duration = time - currentProfile.startTime;
currentProfile.success = false;
currentProfile.result = null;

finalizeFrames(time);
if (typeof profileCallback === "function") {
profileCallback(currentProfile);
}
currentProfile = null;
hookStats = new Map();
}
});
}
};
1 change: 1 addition & 0 deletions lib/Resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ class StackEntry {
* @property {StackEntry | Set<string>=} stack tip of the resolver call stack (a singly-linked list with Set-like API). For instance, `resolve → parsedResolve → describedResolve`. Accepts a legacy `Set<string>` for back-compat with older callers; it is normalized internally without a hot-path branch.
* @property {((str: string) => void)=} log log function
* @property {ResolveContextYield=} yield yield result, if provided plugins can return several results
* @property {((profile: import("./ProfilerPlugin").ProfileData) => void)=} profile profile callback — called with timing data when resolve completes
*/

/** @typedef {AsyncSeriesBailHook<[ResolveRequest, ResolveContext], ResolveRequest | null>} ResolveStepHook */
Expand Down
10 changes: 10 additions & 0 deletions lib/ResolverFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const ModulesInRootPlugin = require("./ModulesInRootPlugin");
const NextPlugin = require("./NextPlugin");
const ParsePlugin = require("./ParsePlugin");
const PnpPlugin = require("./PnpPlugin");
const ProfilerPlugin = require("./ProfilerPlugin");
const Resolver = require("./Resolver");
const RestrictionsPlugin = require("./RestrictionsPlugin");
const ResultPlugin = require("./ResultPlugin");
Expand Down Expand Up @@ -104,6 +105,7 @@ const { PathType, getType, toPath } = require("./util/path");
* @property {boolean=} preferRelative Prefer to resolve module requests as relative requests before falling back to modules
* @property {boolean=} preferAbsolute Prefer to resolve server-relative urls as absolute paths before falling back to resolve in roots
* @property {string | URL | boolean | UserTsconfigOptions=} tsconfig TypeScript config file path (or `file:` `URL` instance) or config object with configFile and references
* @property {boolean | ((profile: import("./ProfilerPlugin").ProfileData) => void)=} profile Enable resolution profiling, optionally with a callback to receive profile data
*/

/**
Expand Down Expand Up @@ -137,6 +139,7 @@ const { PathType, getType, toPath } = require("./util/path");
* @property {boolean} preferRelative prefer relative
* @property {boolean} preferAbsolute prefer absolute
* @property {string | boolean | TsconfigOptions} tsconfig tsconfig file path or config object
* @property {boolean | ((profile: import("./ProfilerPlugin").ProfileData) => void)} profile enable resolution profiling
*/

/**
Expand Down Expand Up @@ -367,6 +370,7 @@ function createOptions(options) {
options.restrictions.map((r) => (r instanceof RegExp ? r : toPath(r))),
),
tsconfig: normalizeTsconfig(options.tsconfig),
profile: options.profile === undefined ? false : options.profile,
};
}

Expand Down Expand Up @@ -407,10 +411,16 @@ module.exports.createResolver = function createResolver(options) {
restrictions,
roots,
tsconfig,
profile,
} = normalizedOptions;

const plugins = [...userPlugins];

if (profile) {
const profileCallback = typeof profile === "function" ? profile : undefined;
plugins.push(new ProfilerPlugin(profileCallback));
}

const resolver =
customResolver || new Resolver(fileSystem, normalizedOptions);

Expand Down
1 change: 1 addition & 0 deletions lib/createInnerContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ module.exports = function createInnerContext(parent, stack, message) {
contextDependencies: parent.contextDependencies,
missingDependencies: parent.missingDependencies,
stack,
profile: parent.profile,
};
};
3 changes: 3 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ module.exports = mergeExports(resolve, {
get LogInfoPlugin() {
return require("./LogInfoPlugin");
},
get ProfilerPlugin() {
return require("./ProfilerPlugin");
},
get TsconfigPathsPlugin() {
return require("./TsconfigPathsPlugin");
},
Expand Down
Loading