@sveltejs/vite-plugin-svelte
Version:
The official [Svelte](https://svelte.dev) plugin for [Vite](https://vitejs.dev).
249 lines (232 loc) • 6.11 kB
JavaScript
/** @import { CollectionOptions, PackageStats, Stat, StatCollection } from '../types/vite-plugin-svelte-stats.js' */
import { log } from './log.js';
import { performance } from 'node:perf_hooks';
import { normalizePath } from 'vite';
import { findClosestPkgJsonPath } from 'vitefu';
import { readFileSync } from 'node:fs';
import { dirname } from 'node:path';
/** @type {CollectionOptions} */
const defaultCollectionOptions = {
// log after 500ms and more than one file processed
logInProgress: (c, now) => now - c.collectionStart > 500 && c.stats.length > 1,
// always log results
logResult: () => true
};
/**
* @param {number} n
* @returns
*/
function humanDuration(n) {
// 99.9ms 0.10s
return n < 100 ? `${n.toFixed(1)}ms` : `${(n / 1000).toFixed(2)}s`;
}
/**
* @param {PackageStats[]} pkgStats
* @returns {string}
*/
function formatPackageStats(pkgStats) {
const statLines = pkgStats.map((pkgStat) => {
const duration = pkgStat.duration;
const avg = duration / pkgStat.files;
return [pkgStat.pkg, `${pkgStat.files}`, humanDuration(duration), humanDuration(avg)];
});
statLines.unshift(['package', 'files', 'time', 'avg']);
const columnWidths = statLines.reduce(
(widths, row) => {
for (let i = 0; i < row.length; i++) {
const cell = row[i];
if (widths[i] < cell.length) {
widths[i] = cell.length;
}
}
return widths;
},
statLines[0].map(() => 0)
);
const table = statLines
.map((row) =>
row
.map((cell, i) => {
if (i === 0) {
return cell.padEnd(columnWidths[i], ' ');
} else {
return cell.padStart(columnWidths[i], ' ');
}
})
.join('\t')
)
.join('\n');
return table;
}
/**
* @class
*/
export class VitePluginSvelteStats {
/** @type {PackageInfo[]} */
#packageInfos = [];
/** @type {StatCollection[]} */
#collections = [];
/**
* @param {string} name
* @param {Partial<CollectionOptions>} [opts]
* @returns {StatCollection}
*/
startCollection(name, opts) {
const options = {
...defaultCollectionOptions,
...opts
};
/** @type {Stat[]} */
const stats = [];
const collectionStart = performance.now();
const _this = this;
let hasLoggedProgress = false;
/** @type {StatCollection} */
const collection = {
name,
options,
stats,
collectionStart,
finished: false,
start(file) {
if (collection.finished) {
throw new Error('called after finish() has been used');
}
file = normalizePath(file);
const start = performance.now();
/** @type {Stat} */
const stat = { file, start, end: start };
return () => {
const now = performance.now();
stat.end = now;
stats.push(stat);
if (!hasLoggedProgress && options.logInProgress(collection, now)) {
hasLoggedProgress = true;
log.debug(`${name} in progress ...`, undefined, 'stats');
}
};
},
async finish() {
await _this.#finish(collection);
}
};
_this.#collections.push(collection);
return collection;
}
async finishAll() {
await Promise.all(this.#collections.map((c) => c.finish()));
}
/**
* @param {StatCollection} collection
*/
async #finish(collection) {
try {
collection.finished = true;
const now = performance.now();
collection.duration = now - collection.collectionStart;
const logResult = collection.options.logResult(collection);
if (logResult) {
await this.#aggregateStatsResult(collection);
log.debug(
`${collection.name} done.\n${formatPackageStats(
/** @type {PackageStats[]}*/ (collection.packageStats)
)}`,
undefined,
'stats'
);
}
// cut some ties to free it for garbage collection
const index = this.#collections.indexOf(collection);
this.#collections.splice(index, 1);
collection.stats.length = 0;
collection.stats = [];
if (collection.packageStats) {
collection.packageStats.length = 0;
collection.packageStats = [];
}
collection.start = () => () => {};
collection.finish = () => {};
} catch (e) {
// this should not happen, but stats taking also should not break the process
log.debug.once(`failed to finish stats for ${collection.name}\n`, e, 'stats');
}
}
/**
* @param {StatCollection} collection
*/
async #aggregateStatsResult(collection) {
const stats = collection.stats;
for (const stat of stats) {
stat.pkg = (await this.#getPackageInfo(stat.file)).name;
}
// group stats
/** @type {Record<string, PackageStats>} */
const grouped = {};
stats.forEach((stat) => {
const pkg = /** @type {string} */ (stat.pkg);
let group = grouped[pkg];
if (!group) {
group = grouped[pkg] = {
files: 0,
duration: 0,
pkg
};
}
group.files += 1;
group.duration += stat.end - stat.start;
});
const groups = Object.values(grouped);
groups.sort((a, b) => b.duration - a.duration);
collection.packageStats = groups;
}
/**
* @param {string} file
* @returns {Promise<PackageInfo>}
*/
async #getPackageInfo(file) {
let info = this.#packageInfos.find((pi) => file.startsWith(pi.path));
if (!info) {
info = await findPackageInfo(file);
this.#packageInfos.push(info);
}
return info;
}
}
/**
* @typedef {{
* name: string;
* version: string;
* svelte?: string;
* path: string;
* }} PackageInfo
*/
/**
* utility to get some info from the closest package.json with a "name" set
*
* @param {string} file to find info for
* @returns {Promise<PackageInfo>}
*/
async function findPackageInfo(file) {
/** @type {PackageInfo} */
const info = {
name: '$unknown',
version: '0.0.0-unknown',
path: '$unknown'
};
let path = await findClosestPkgJsonPath(file, (pkgPath) => {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
if (pkg.name != null) {
info.name = pkg.name;
if (pkg.version != null) {
info.version = pkg.version;
}
info.svelte = pkg.svelte;
return true;
}
return false;
});
// return normalized path with appended '/' so .startsWith works for future file checks
path = normalizePath(dirname(path ?? file)) + '/';
info.path = path;
return info;
}