UNPKG

vite-plugin-inspect

Version:

Inspect the intermediate state of Vite plugins

700 lines (690 loc) 22 kB
import process from 'node:process'; import c from 'ansis'; import { debounce } from 'perfect-debounce'; import sirv from 'sirv'; import { createRPCServer } from 'vite-dev-rpc'; import { DIR_CLIENT } from '../dirs.mjs'; import fs from 'node:fs/promises'; import { isAbsolute, resolve, join } from 'node:path'; import { hash } from 'ohash'; import { Buffer } from 'node:buffer'; import { createFilter } from 'unplugin-utils'; import Debug from 'debug'; import { parse } from 'error-stack-parser-es'; import { createServer } from 'node:http'; async function generateBuild(ctx) { const { outputDir = ".vite-inspect" } = ctx.options; const targetDir = isAbsolute(outputDir) ? outputDir : resolve(process.cwd(), outputDir); const reportsDir = join(targetDir, "reports"); await fs.rm(targetDir, { recursive: true, force: true }); await fs.mkdir(reportsDir, { recursive: true }); await fs.cp(DIR_CLIENT, targetDir, { recursive: true }); await Promise.all([ fs.writeFile( join(targetDir, "index.html"), (await fs.readFile(join(targetDir, "index.html"), "utf-8")).replace( 'data-vite-inspect-mode="DEV"', 'data-vite-inspect-mode="BUILD"' ) ), writeJSON( join(reportsDir, "metadata.json"), ctx.getMetadata() ), ...[...ctx._idToInstances.values()].flatMap( (v) => [...v.environments.values()].map((e) => { const key = `${v.id}-${e.env.name}`; return [key, e]; }) ).map(async ([key, env]) => { await fs.mkdir(join(reportsDir, key, "transforms"), { recursive: true }); return await Promise.all([ writeJSON( join(reportsDir, key, "modules.json"), env.getModulesList() ), writeJSON( join(reportsDir, key, "metric-plugins.json"), env.getPluginMetrics() ), ...Object.entries(env.data.transform).map(([id, info]) => writeJSON( join(reportsDir, key, "transforms", `${hash(id)}.json`), { resolvedId: id, transforms: info } )) ]); }) ]); return targetDir; } function writeJSON(filename, data) { return fs.writeFile(filename, `${JSON.stringify(data, null, 2)} `); } const DUMMY_LOAD_PLUGIN_NAME = "__load__"; async function openBrowser(address) { await import('open').then((r) => r.default(address, { newInstance: true })).catch(() => { }); } function serializePlugin(plugin) { return JSON.parse(JSON.stringify(plugin, (key, value) => { if (typeof value === "function") { let name = value.name; if (name === "anonymous") name = ""; if (name === key) name = ""; if (name) return `[Function ${name}]`; return "[Function]"; } if (key === "api" && value) return "[Object API]"; return value; })); } function removeVersionQuery(url) { if (url.includes("v=")) { return url.replace(/&v=\w+/, "").replace(/\?v=\w+/, "?").replace(/\?$/, ""); } return url; } let viteCount = 0; class InspectContext { constructor(options) { this.options = options; this.filter = createFilter(options.include, options.exclude); } _configToInstances = /* @__PURE__ */ new Map(); _idToInstances = /* @__PURE__ */ new Map(); filter; getMetadata() { return { instances: [...this._idToInstances.values()].map((vite) => ({ root: vite.config.root, vite: vite.id, plugins: vite.config.plugins.map((i) => serializePlugin(i)), environments: [...vite.environments.keys()], environmentPlugins: Object.fromEntries( [...vite.environments.entries()].map(([name, env]) => { return [name, env.env.getTopLevelConfig().plugins.map((i) => vite.config.plugins.indexOf(i))]; }) ) })), embedded: this.options.embedded }; } getViteContext(configOrId) { if (typeof configOrId === "string") { if (!this._idToInstances.has(configOrId)) throw new Error(`Can not found vite context for ${configOrId}`); return this._idToInstances.get(configOrId); } if (this._configToInstances.has(configOrId)) return this._configToInstances.get(configOrId); const id = `vite${++viteCount}`; const vite = new InspectContextVite(id, this, configOrId); this._idToInstances.set(id, vite); this._configToInstances.set(configOrId, vite); return vite; } getEnvContext(env) { if (!env) return void 0; const vite = this.getViteContext(env.getTopLevelConfig()); return vite.getEnvContext(env); } queryEnv(query) { const vite = this.getViteContext(query.vite); const env = vite.getEnvContext(query.env); return env; } } class InspectContextVite { constructor(id, context, config) { this.id = id; this.context = context; this.config = config; } environments = /* @__PURE__ */ new Map(); data = { serverMetrics: { middleware: {} } }; getEnvContext(env) { if (typeof env === "string") { if (!this.environments.has(env)) throw new Error(`Can not found environment context for ${env}`); return this.environments.get(env); } if (env.getTopLevelConfig() !== this.config) throw new Error("Environment config does not match Vite config"); if (!this.environments.has(env.name)) this.environments.set(env.name, new InspectContextViteEnv(this.context, this, env)); return this.environments.get(env.name); } } class InspectContextViteEnv { constructor(contextMain, contextVite, env) { this.contextMain = contextMain; this.contextVite = contextVite; this.env = env; } data = { transform: {}, resolveId: {}, transformCounter: {} }; recordTransform(id, info, preTransformCode) { id = this.normalizeId(id); if (!this.data.transform[id] || !this.data.transform[id].some((tr) => tr.result)) { this.data.transform[id] = [{ name: DUMMY_LOAD_PLUGIN_NAME, result: preTransformCode, start: info.start, end: info.start, sourcemaps: info.sourcemaps }]; this.data.transformCounter[id] = (this.data.transformCounter[id] || 0) + 1; } this.data.transform[id].push(info); } recordLoad(id, info) { id = this.normalizeId(id); this.data.transform[id] = [info]; this.data.transformCounter[id] = (this.data.transformCounter[id] || 0) + 1; } recordResolveId(id, info) { id = this.normalizeId(id); if (!this.data.resolveId[id]) this.data.resolveId[id] = []; this.data.resolveId[id].push(info); } invalidate(id) { id = this.normalizeId(id); delete this.data.transform[id]; } normalizeId(id) { if (this.contextMain.options.removeVersionQuery !== false) return removeVersionQuery(id); return id; } getModulesList() { const moduleGraph = this.env.mode === "dev" ? this.env.moduleGraph : void 0; const getDeps = (id) => Array.from(moduleGraph?.getModuleById(id)?.importedModules || []).map((i) => i.id || "").filter(Boolean); const getImporters = (id) => Array.from(moduleGraph?.getModuleById(id)?.importers || []).map((i) => i.id || "").filter(Boolean); function isVirtual(pluginName, transformName) { return pluginName !== DUMMY_LOAD_PLUGIN_NAME && transformName !== "vite:load-fallback" && transformName !== "vite:build-load-fallback"; } const transformedIdMap = Object.values(this.data.resolveId).reduce((map, ids2) => { ids2.forEach((id) => { map[id.result] ??= []; map[id.result].push(id); }); return map; }, {}); const ids = new Set(Object.keys(this.data.transform).concat(Object.keys(transformedIdMap))); return Array.from(ids).sort().map((id) => { let totalTime = 0; const plugins = (this.data.transform[id] || []).filter((tr) => tr.result).map((transItem) => { const delta = transItem.end - transItem.start; totalTime += delta; return { name: transItem.name, transform: delta }; }).concat( // @ts-expect-error transform is optional (transformedIdMap[id] || []).map((idItem) => { return { name: idItem.name, resolveId: idItem.end - idItem.start }; }) ); function getSize(str) { if (!str) return 0; return Buffer.byteLength(str, "utf8"); } return { id, deps: getDeps(id), importers: getImporters(id), plugins, virtual: isVirtual(plugins[0]?.name || "", this.data.transform[id]?.[0].name || ""), totalTime, invokeCount: this.data.transformCounter?.[id] || 0, sourceSize: getSize(this.data.transform[id]?.[0]?.result), distSize: getSize(this.data.transform[id]?.[this.data.transform[id].length - 1]?.result) }; }); } resolveId(id = "", ssr = false) { if (id.startsWith("./")) id = resolve(this.env.getTopLevelConfig().root, id).replace(/\\/g, "/"); return this.resolveIdRecursive(id, ssr); } resolveIdRecursive(id, ssr = false) { const resolved = this.data.resolveId[id]?.[0]?.result; return resolved ? this.resolveIdRecursive(resolved, ssr) : id; } getPluginMetrics() { const map = {}; const defaultMetricInfo = () => ({ transform: { invokeCount: 0, totalTime: 0 }, resolveId: { invokeCount: 0, totalTime: 0 } }); this.env.getTopLevelConfig().plugins.forEach((i) => { map[i.name] = { ...defaultMetricInfo(), name: i.name, enforce: i.enforce }; }); Object.values(this.data.transform).forEach((transformInfos) => { transformInfos.forEach(({ name, start, end }) => { if (name === DUMMY_LOAD_PLUGIN_NAME) return; if (!map[name]) map[name] = { ...defaultMetricInfo(), name }; map[name].transform.totalTime += end - start; map[name].transform.invokeCount += 1; }); }); Object.values(this.data.resolveId).forEach((resolveIdInfos) => { resolveIdInfos.forEach(({ name, start, end }) => { if (!map[name]) map[name] = { ...defaultMetricInfo(), name }; map[name].resolveId.totalTime += end - start; map[name].resolveId.invokeCount += 1; }); }); const metrics = Object.values(map).filter(Boolean).sort((a, b) => a.name.localeCompare(b.name)); return metrics; } async getModuleTransformInfo(id, clear = false) { if (clear) { this.clearId(id); try { if (this.env.mode === "dev") await this.env.transformRequest(id); } catch { } } const resolvedId = this.resolveId(id); return { resolvedId, transforms: this.data.transform[resolvedId] || [] }; } clearId(_id) { const id = this.resolveId(_id); if (id) { const moduleGraph = this.env.mode === "dev" ? this.env.moduleGraph : void 0; const mod = moduleGraph?.getModuleById(id); if (mod) moduleGraph?.invalidateModule(mod); this.invalidate(id); } } } const debug = Debug("vite-plugin-inspect"); function hijackHook(plugin, name, wrapper) { if (!plugin[name]) return; debug(`hijack plugin "${name}"`, plugin.name); let order = plugin.order || plugin.enforce || "normal"; const hook = plugin[name]; if ("handler" in hook) { const oldFn = hook.handler; order += `-${hook.order || hook.enforce || "normal"}`; hook.handler = function(...args) { return wrapper(oldFn, this, args, order); }; } else if ("transform" in hook) { const oldFn = hook.transform; order += `-${hook.order || hook.enforce || "normal"}`; hook.transform = function(...args) { return wrapper(oldFn, this, args, order); }; } else { const oldFn = hook; plugin[name] = function(...args) { return wrapper(oldFn, this, args, order); }; } } const hijackedPlugins = /* @__PURE__ */ new WeakSet(); function hijackPlugin(plugin, ctx) { if (hijackedPlugins.has(plugin)) return; hijackedPlugins.add(plugin); hijackHook(plugin, "transform", async (fn, context, args, order) => { const code = args[0]; const id = args[1]; let _result; let error; const start = Date.now(); try { _result = await fn.apply(context, args); } catch (_err) { error = _err; } const end = Date.now(); const result = error ? "[Error]" : typeof _result === "string" ? _result : _result?.code; if (ctx.filter(id)) { const sourcemaps = typeof _result === "string" ? null : _result?.map; ctx.getEnvContext(context?.environment)?.recordTransform(id, { name: plugin.name, result, start, end, order, sourcemaps, error: error ? parseError(error) : void 0 }, code); } if (error) throw error; return _result; }); hijackHook(plugin, "load", async (fn, context, args) => { const id = args[0]; let _result; let error; const start = Date.now(); try { _result = await fn.apply(context, args); } catch (err) { error = err; } const end = Date.now(); const result = error ? "[Error]" : typeof _result === "string" ? _result : _result?.code; const sourcemaps = typeof _result === "string" ? null : _result?.map; if (result) { ctx.getEnvContext(context?.environment)?.recordLoad(id, { name: plugin.name, result, start, end, sourcemaps, error: error ? parseError(error) : void 0 }); } if (error) throw error; return _result; }); hijackHook(plugin, "resolveId", async (fn, context, args) => { const id = args[0]; let _result; let error; const start = Date.now(); try { _result = await fn.apply(context, args); } catch (err) { error = err; } const end = Date.now(); if (!ctx.filter(id)) { if (error) throw error; return _result; } const result = error ? stringifyError(error) : typeof _result === "object" ? _result?.id : _result; if (result && result !== id) { ctx.getEnvContext(context?.environment)?.recordResolveId(id, { name: plugin.name, result, start, end, error }); } if (error) throw error; return _result; }); } function parseError(error) { const stack = parse(error, { allowEmpty: true }); const message = error.message || String(error); return { message, stack, raw: error }; } function stringifyError(err) { return String(err.stack ? err.stack : err); } function createPreviewServer(staticPath) { const server = createServer(); const statics = sirv(staticPath); server.on("request", (req, res) => { statics(req, res, () => { res.statusCode = 404; res.end("File not found"); }); }); server.listen(0, () => { const { port } = server.address(); const url = `http://localhost:${port}`; console.log(` ${c.green("\u279C")} ${c.bold("Inspect Preview Started")}: ${url}`); openBrowser(url); }); } function createServerRpc(ctx) { const rpc = { async getMetadata() { return ctx.getMetadata(); }, async getModulesList(query) { return ctx.queryEnv(query).getModulesList(); }, async getPluginMetrics(query) { return ctx.queryEnv(query).getPluginMetrics(); }, async getModuleTransformInfo(query, id, clear) { return ctx.queryEnv(query).getModuleTransformInfo(id, clear); }, async resolveId(query, id) { return ctx.queryEnv(query).resolveId(id); }, async getServerMetrics(query) { return ctx.getViteContext(query.vite).data.serverMetrics || {}; }, async onModuleUpdated() { }, async list() { return { root: ctx.getViteContext("vite1").config.root, modules: await ctx.queryEnv({ vite: "vite1", env: "client" }).getModulesList(), ssrModules: await ctx.queryEnv({ vite: "vite1", env: "server" }).getModulesList() }; } }; return rpc; } const NAME = "vite-plugin-inspect"; const isCI = !!process.env.CI; function PluginInspect(options = {}) { const { dev = true, build = false, silent = false, open: _open = false } = options; if (!dev && !build) { return { name: NAME }; } const ctx = new InspectContext(options); const timestampRE = /\bt=\d{13}&?\b/; const trailingSeparatorRE = /[?&]$/; function setupMiddlewarePerf(ctx2, middlewares) { let firstMiddlewareIndex = -1; middlewares.forEach((middleware, index) => { const { handle: originalHandle } = middleware; if (typeof originalHandle !== "function" || !originalHandle.name) return middleware; middleware.handle = function(...middlewareArgs) { let req; if (middlewareArgs.length === 4) [, req] = middlewareArgs; else [req] = middlewareArgs; const start = Date.now(); const url = req.url?.replace(timestampRE, "").replace(trailingSeparatorRE, ""); ctx2.data.serverMetrics.middleware[url] ??= []; if (firstMiddlewareIndex < 0) firstMiddlewareIndex = index; if (index === firstMiddlewareIndex) ctx2.data.serverMetrics.middleware[url] = []; const result = originalHandle.apply(this, middlewareArgs); Promise.resolve(result).then(() => { const total = Date.now() - start; const metrics = ctx2.data.serverMetrics.middleware[url]; ctx2.data.serverMetrics.middleware[url].push({ self: metrics.length ? Math.max(total - metrics[metrics.length - 1].total, 0) : total, total, name: originalHandle.name }); }); return result; }; Object.defineProperty(middleware.handle, "name", { value: originalHandle.name, configurable: true, enumerable: true }); return middleware; }); } function configureServer(server) { const config = server.config; Object.values(server.environments).forEach((env) => { const envCtx = ctx.getEnvContext(env); const _invalidateModule = env.moduleGraph.invalidateModule; env.moduleGraph.invalidateModule = function(...args) { const mod = args[0]; if (mod?.id) envCtx.invalidate(mod.id); return _invalidateModule.apply(this, args); }; }); const base = (options.base ?? server.config.base) || "/"; server.middlewares.use(`${base}__inspect`, sirv(DIR_CLIENT, { single: true, dev: true })); const rpc = createServerRpc(ctx); const rpcServer = createRPCServer( "vite-plugin-inspect", server.ws, rpc ); const debouncedModuleUpdated = debounce(() => { rpcServer.onModuleUpdated.asEvent(); }, 100); server.middlewares.use((req, res, next) => { debouncedModuleUpdated(); next(); }); const _print = server.printUrls; server.printUrls = () => { let host = `${config.server.https ? "https" : "http"}://localhost:${config.server.port || "80"}`; const url = server.resolvedUrls?.local[0]; if (url) { try { const u = new URL(url); host = `${u.protocol}//${u.host}`; } catch (error) { config.logger.warn(`Parse resolved url failed: ${error}`); } } _print(); if (!silent) { const colorUrl = (url2) => c.green(url2.replace(/:(\d+)\//, (_, port) => `:${c.bold(port)}/`)); config.logger.info(` ${c.green("\u279C")} ${c.bold("Inspect")}: ${colorUrl(`${host}${base}__inspect/`)}`); } if (_open && !isCI) { setTimeout(() => { openBrowser(`${host}${base}__inspect/`); }, 500); } }; return rpc; } const plugin = { name: NAME, enforce: "pre", apply(_, { command }) { if (command === "serve" && dev) return true; if (command === "build" && build) return true; return false; }, configResolved(config) { config.plugins.forEach((plugin2) => hijackPlugin(plugin2, ctx)); const _createResolver = config.createResolver; config.createResolver = function(...args) { const _resolver = _createResolver.apply(this, args); return async function(...args2) { const id = args2[0]; const aliasOnly = args2[2]; const ssr = args2[3]; const start = Date.now(); const result = await _resolver.apply(this, args2); const end = Date.now(); if (result && result !== id) { const pluginName = aliasOnly ? "alias" : "vite:resolve (+alias)"; const vite = ctx.getViteContext(config); const env = vite.getEnvContext(ssr ? "ssr" : "client"); env.recordResolveId(id, { name: pluginName, result, start, end }); } return result; }; }; }, configureServer(server) { const rpc = configureServer(server); plugin.api = { rpc }; return () => { setupMiddlewarePerf( ctx.getViteContext(server.config), server.middlewares.stack ); }; }, load: { order: "pre", handler(id) { ctx.getEnvContext(this.environment)?.invalidate(id); return null; } }, hotUpdate({ modules, environment }) { const ids = modules.map((module) => module.id); environment.hot.send({ type: "custom", event: "vite-plugin-inspect:update", data: { ids } }); }, async buildEnd() { if (!build) return; const dir = await generateBuild(ctx); this.environment.logger.info(`${c.green("Inspect report generated at")} ${c.dim(dir)}`); if (_open && !isCI) createPreviewServer(dir); } }; return plugin; } export { PluginInspect as P };