From 6199eee10788b4413e348976932c7edd084950f6 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 22 Feb 2026 19:49:22 +0300 Subject: [PATCH 1/3] refactor: adding typescript jsdocs types --- package-lock.json | 20 ++++ package.json | 6 +- src/BundleAnalyzerPlugin.js | 107 ++++++++++++++++--- src/Logger.js | 69 ++++++++++-- src/analyzer.js | 190 +++++++++++++++++++++++++++------ src/bin/analyzer.js | 20 +++- src/parseUtils.js | 171 +++++++++++++++++++++++++---- src/sizeUtils.js | 19 ++-- src/statsUtils.js | 16 +++ src/template.js | 41 ++++++- src/tree/BaseFolder.js | 93 +++++++++++++--- src/tree/ConcatenatedModule.js | 69 ++++++++++-- src/tree/ContentFolder.js | 28 +++++ src/tree/ContentModule.js | 32 +++++- src/tree/Folder.js | 41 ++++++- src/tree/Module.js | 55 +++++++++- src/tree/Node.js | 8 ++ src/tree/utils.js | 15 ++- src/utils.js | 31 +++++- src/viewer.js | 146 ++++++++++++++++++++----- tsconfig.json | 13 +++ 21 files changed, 1051 insertions(+), 139 deletions(-) create mode 100644 tsconfig.json diff --git a/package-lock.json b/package-lock.json index 2b9e61bb..d27341a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,8 @@ "@babel/preset-react": "^7.26.3", "@babel/runtime": "^7.26.9", "@carrotsearch/foamtree": "^3.5.0", + "@types/html-escaper": "^3.0.4", + "@types/opener": "^1.4.3", "autoprefixer": "^10.2.5", "babel-eslint": "^10.1.0", "babel-loader": "^10.0.0", @@ -56,6 +58,7 @@ "style-loader": "^4.0.0", "terser-webpack-plugin": "^5.1.2", "tinyglobby": "^0.2.15", + "typescript": "^5.9.3", "webpack": "^5.105.2", "webpack-4": "npm:webpack@^4", "webpack-cli": "^6.0.1", @@ -4014,6 +4017,13 @@ "@types/send": "*" } }, + "node_modules/@types/html-escaper": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/html-escaper/-/html-escaper-3.0.4.tgz", + "integrity": "sha512-UKSaMPMXXKnq1jDj74seVikfdq5pWvoXcIgOUbwYzHuAEGiv8/juom1i/MsWBF8boFSI0uHQCSZauzr5OYnnJA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4106,6 +4116,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/opener": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@types/opener/-/opener-1.4.3.tgz", + "integrity": "sha512-g7TYSmy2RKZkU3QT/9pMISrhVmQtMNaYq6Aojn3Y6pht29Nu9VuijJCYIjofRj7ZaFtKdxh1I8xf3vdW4l86fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", diff --git a/package.json b/package.json index a2649ab4..357ab425 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,9 @@ "watch:analyzer": "npm run build:analyzer -- --watch", "watch:viewer": "npm run build:viewer -- --node-env=development --watch", "npm-publish": "npm run lint && npm run build && npm test && npm publish", - "lint": "npm run lint:code && npm run fmt:check", + "lint": "npm run lint:code && npm run lint:types && npm run fmt:check", "lint:code": "eslint --cache .", + "lint:types": "tsc --pretty --noEmit", "fmt": "npm run fmt:base -- --log-level warn --write", "fmt:check": "npm run fmt:base -- --check", "fmt:base": "prettier --cache --ignore-unknown .", @@ -71,6 +72,8 @@ "@babel/preset-react": "^7.26.3", "@babel/runtime": "^7.26.9", "@carrotsearch/foamtree": "^3.5.0", + "@types/html-escaper": "^3.0.4", + "@types/opener": "^1.4.3", "autoprefixer": "^10.2.5", "babel-eslint": "^10.1.0", "babel-loader": "^10.0.0", @@ -95,6 +98,7 @@ "style-loader": "^4.0.0", "terser-webpack-plugin": "^5.1.2", "tinyglobby": "^0.2.15", + "typescript": "^5.9.3", "webpack": "^5.105.2", "webpack-4": "npm:webpack@^4", "webpack-cli": "^6.0.1", diff --git a/src/BundleAnalyzerPlugin.js b/src/BundleAnalyzerPlugin.js index 65287b4f..c682ad7e 100644 --- a/src/BundleAnalyzerPlugin.js +++ b/src/BundleAnalyzerPlugin.js @@ -7,8 +7,54 @@ const { writeStats } = require("./statsUtils"); const utils = require("./utils"); const viewer = require("./viewer"); +/** @typedef {import("net").AddressInfo} AddressInfo */ +/** @typedef {import("webpack").Compiler} Compiler */ +/** @typedef {import("webpack").OutputFileSystem} OutputFileSystem */ +/** @typedef {import("webpack").Stats} Stats */ +/** @typedef {import("webpack").StatsOptions} StatsOptions */ +/** @typedef {import("webpack").StatsAsset} StatsAsset */ +/** @typedef {import("webpack").StatsCompilation} StatsCompilation */ +/** @typedef {import("./sizeUtils").Algorithm} CompressionAlgorithm */ +/** @typedef {import("./Logger").Level} LogLever */ +/** @typedef {import("./viewer").ViewerServerObj} ViewerServerObj */ + +/** @typedef {string | boolean | StatsOptions} PluginStatsOptions */ + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** @typedef {"static" | "json" | "server" | "disabled"} Mode */ +/** @typedef {string | RegExp | ((asset: string) => void)} Pattern */ +/** @typedef {null | Pattern | Pattern[]} ExcludeAssets */ +/** @typedef {"stat" | "parsed" | "gzip" | "brotli" | "zstd"} Sizes */ +/** @typedef {string | (() => string)} ReportTitle */ +/** @typedef {(options: { listenHost: string, listenPort: number, boundAddress: string | AddressInfo | null }) => string} AnalyzerUrl */ + +/** + * @typedef {object} Options + * @property {Mode=} analyzerMode analyzer mode + * @property {string=} analyzerHost analyzer host + * @property {"auto" | number=} analyzerPort analyzer port + * @property {CompressionAlgorithm=} compressionAlgorithm compression algorithm + * @property {string | null=} reportFilename report filename + * @property {ReportTitle=} reportTitle report title + * @property {Sizes=} defaultSizes default sizes + * @property {boolean=} openAnalyzer open analyzer + * @property {boolean=} generateStatsFile generate stats file + * @property {string=} statsFilename stats filename + * @property {PluginStatsOptions=} statsOptions stats options + * @property {ExcludeAssets=} excludeAssets exclude assets + * @property {LogLever=} logLevel exclude assets + * @property {boolean=} startAnalyzer start analyzer + * @property {AnalyzerUrl=} analyzerUrl start analyzer + */ + class BundleAnalyzerPlugin { + /** + * @param {Options=} opts options + */ constructor(opts = {}) { + /** @type {Required> & { analyzerPort: number, statsOptions: undefined | PluginStatsOptions }} */ this.opts = { analyzerMode: "server", analyzerHost: "127.0.0.1", @@ -19,31 +65,38 @@ class BundleAnalyzerPlugin { openAnalyzer: true, generateStatsFile: false, statsFilename: "stats.json", - statsOptions: null, + statsOptions: undefined, excludeAssets: null, logLevel: "info", - // deprecated + // TODO deprecated startAnalyzer: true, analyzerUrl: utils.defaultAnalyzerUrl, ...opts, analyzerPort: - "analyzerPort" in opts - ? opts.analyzerPort === "auto" - ? 0 - : opts.analyzerPort - : 8888, + opts.analyzerPort === "auto" ? 0 : (opts.analyzerPort ?? 8888), }; + /** @type {Compiler | null} */ + this.compiler = null; + /** @type {Promise | null} */ this.server = null; this.logger = new Logger(this.opts.logLevel); } + /** + * @param {Compiler} compiler compiler + */ apply(compiler) { this.compiler = compiler; + /** + * @param {Stats} stats stats + * @param {(err?: Error) => void} callback callback + */ const done = (stats, callback) => { callback ||= () => {}; + /** @type {(() => Promise)[]} */ const actions = []; if (this.opts.generateStatsFile) { @@ -72,7 +125,7 @@ class BundleAnalyzerPlugin { await Promise.all(actions.map((action) => action())); callback(); } catch (err) { - callback(err); + callback(/** @type {Error} */ (err)); } }); } else { @@ -83,13 +136,19 @@ class BundleAnalyzerPlugin { if (compiler.hooks) { compiler.hooks.done.tapAsync("webpack-bundle-analyzer", done); } else { + // @ts-expect-error old webpack@4 API compiler.plugin("done", done); } } + /** + * @param {StatsCompilation} stats stats + * @returns {Promise} + */ async generateStatsFile(stats) { const statsFilepath = path.resolve( - this.compiler.outputPath, + /** @type {Compiler} */ + (this.compiler).outputPath, this.opts.statsFilename, ); await fs.promises.mkdir(path.dirname(statsFilepath), { recursive: true }); @@ -107,6 +166,10 @@ class BundleAnalyzerPlugin { } } + /** + * @param {StatsCompilation} stats stats + * @returns {Promise} + */ async startAnalyzerServer(stats) { if (this.server) { (await this.server).updateChartData(stats); @@ -126,10 +189,15 @@ class BundleAnalyzerPlugin { } } + /** + * @param {StatsCompilation} stats stats + * @returns {Promise} + */ async generateJSONReport(stats) { await viewer.generateJSONReport(stats, { reportFilename: path.resolve( - this.compiler.outputPath, + /** @type {Compiler} */ + (this.compiler).outputPath, this.opts.reportFilename || "report.json", ), compressionAlgorithm: this.opts.compressionAlgorithm, @@ -139,11 +207,16 @@ class BundleAnalyzerPlugin { }); } + /** + * @param {StatsCompilation} stats stats + * @returns {Promise} + */ async generateStaticReport(stats) { await viewer.generateReport(stats, { openBrowser: this.opts.openAnalyzer, reportFilename: path.resolve( - this.compiler.outputPath, + /** @type {Compiler} */ + (this.compiler).outputPath, this.opts.reportFilename || "report.html", ), reportTitle: this.opts.reportTitle, @@ -156,10 +229,14 @@ class BundleAnalyzerPlugin { } getBundleDirFromCompiler() { - if (typeof this.compiler.outputFileSystem.constructor === "undefined") { - return this.compiler.outputPath; + const outputFileSystemConstructor = + /** @type {OutputFileSystem} */ + (/** @type {Compiler} */ (this.compiler).outputFileSystem).constructor; + + if (typeof outputFileSystemConstructor === "undefined") { + return /** @type {Compiler} */ (this.compiler).outputPath; } - switch (this.compiler.outputFileSystem.constructor.name) { + switch (outputFileSystemConstructor.name) { case "MemoryFileSystem": return null; // Detect AsyncMFS used by Nuxt 2.5 that replaces webpack's MFS during development @@ -167,7 +244,7 @@ class BundleAnalyzerPlugin { case "AsyncMFS": return null; default: - return this.compiler.outputPath; + return /** @type {Compiler} */ (this.compiler).outputPath; } } } diff --git a/src/Logger.js b/src/Logger.js index 340fee9d..c278a836 100644 --- a/src/Logger.js +++ b/src/Logger.js @@ -1,5 +1,11 @@ +/** @typedef {import("./BundleAnalyzerPlugin").EXPECTED_ANY} EXPECTED_ANY */ + +/** @typedef {"debug" | "info" | "warn" | "error" | "silent"} Level */ + +/** @type {Level[]} */ const LEVELS = ["debug", "info", "warn", "error", "silent"]; +/** @type {Map} */ const LEVEL_TO_CONSOLE_METHOD = new Map([ ["debug", "log"], ["info", "log"], @@ -7,15 +13,24 @@ const LEVEL_TO_CONSOLE_METHOD = new Map([ ]); class Logger { + /** @type {Level[]} */ static levels = LEVELS; + /** @type {Level} */ static defaultLevel = "info"; + /** + * @param {Level=} level level + */ constructor(level = Logger.defaultLevel) { + /** @type {Set} */ this.activeLevels = new Set(); this.setLogLevel(level); } + /** + * @param {Level} level level + */ setLogLevel(level) { const levelIndex = LEVELS.indexOf(level); @@ -32,18 +47,54 @@ class Logger { } } - _log(level, ...args) { - // eslint-disable-next-line no-console - console[LEVEL_TO_CONSOLE_METHOD.get(level) || level](...args); + /** + * @template {EXPECTED_ANY[]} T + * @param {T} args args + */ + debug(...args) { + if (!this.activeLevels.has("debug")) return; + this._log("debug", ...args); } -} -for (const level of LEVELS) { - if (level === "silent") continue; + /** + * @template {EXPECTED_ANY[]} T + * @param {T} args args + */ + info(...args) { + if (!this.activeLevels.has("info")) return; + this._log("info", ...args); + } - Logger.prototype[level] = function log(...args) { - if (this.activeLevels.has(level)) this._log(level, ...args); - }; + /** + * @template {EXPECTED_ANY[]} T + * @param {T} args args + */ + error(...args) { + if (!this.activeLevels.has("error")) return; + this._log("error", ...args); + } + + /** + * @template {EXPECTED_ANY[]} T + * @param {T} args args + */ + warn(...args) { + if (!this.activeLevels.has("warn")) return; + this._log("warn", ...args); + } + + /** + * @template {EXPECTED_ANY[]} T + * @param {Level} level level + * @param {T} args args + */ + _log(level, ...args) { + // eslint-disable-next-line no-console + console[ + /** @type {Exclude} */ + (LEVEL_TO_CONSOLE_METHOD.get(level) || level) + ](...args); + } } module.exports = Logger; diff --git a/src/analyzer.js b/src/analyzer.js index a3443400..f0634b2b 100644 --- a/src/analyzer.js +++ b/src/analyzer.js @@ -12,8 +12,24 @@ const { createAssetsFilter } = require("./utils"); const FILENAME_QUERY_REGEXP = /\?.*$/u; const FILENAME_EXTENSIONS = /\.(js|mjs|cjs|bundle)$/iu; -function createModulesTree(modules, opts) { - const root = new Folder(".", opts); +/** @typedef {import("webpack").StatsCompilation} StatsCompilation */ +/** @typedef {import("webpack").StatsModule} StatsModule */ +/** @typedef {import("webpack").StatsAsset} StatsAsset */ +/** @typedef {import("./BundleAnalyzerPlugin").CompressionAlgorithm} CompressionAlgorithm */ +/** @typedef {import("./BundleAnalyzerPlugin").ExcludeAssets} ExcludeAssets */ + +/** + * @typedef {object} AnalyzerOptions + * @property {"gzip" | "brotli" | "zstd"} compressionAlgorithm compression algorithm + */ + +/** + * @param {StatsModule[]} modules modules + * @param {AnalyzerOptions} options options + * @returns {Folder} a folder class + */ +function createModulesTree(modules, options) { + const root = new Folder(".", options); for (const module of modules) { root.addModule(module); @@ -36,6 +52,12 @@ function createModulesTree(modules, opts) { * * TODO: replace with Array.prototype.flat once Node.js 10 support is dropped */ +/** + * Flattens an array by one level. + * @template T + * @param {(T | T[])[]} arr the array to flatten + * @returns {T[]} a new array containing the flattened elements + */ function flatten(arr) { if (!arr) return []; const len = arr.length; @@ -55,67 +77,144 @@ function flatten(arr) { return res; } +/** + * @param {StatsCompilation} bundleStats bundle stats + * @param {string} assetName asset name + * @returns {boolean} child asset bundlers + */ function getChildAssetBundles(bundleStats, assetName) { return flatten( - (bundleStats.children || []).find((child) => - Object.values(child.assetsByChunkName), + (bundleStats.children || /** @type {StatsCompilation} */ ([])).find( + /** + * @param {StatsCompilation} child child stats + * @returns {string[][]} assets by chunk name + */ + (child) => Object.values(child.assetsByChunkName || []), ), ).includes(assetName); } -function assetHasModule(statAsset, statModule) { +/** + * @param {StatsAsset} statsAsset stats asset + * @param {StatsModule} statsModule stats modules + * @returns {boolean} true when asset has a module + */ +function assetHasModule(statsAsset, statsModule) { // Checking if this module is the part of asset chunks - return (statModule.chunks || []).some((moduleChunk) => - statAsset.chunks.includes(moduleChunk), + return (statsModule.chunks || []).some( + (moduleChunk) => + statsAsset.chunks && statsAsset.chunks.includes(moduleChunk), ); } -function isRuntimeModule(statModule) { - return statModule.moduleType === "runtime"; +/** + * @param {StatsModule} statsModule stats Module + * @returns {boolean} true when runtime modules, otherwise false + */ +function isRuntimeModule(statsModule) { + return statsModule.moduleType === "runtime"; } +/** + * @param {StatsCompilation} bundleStats bundle stats + * @returns {StatsModule[]} modules + */ function getBundleModules(bundleStats) { + /** @type {Set} */ const seenIds = new Set(); - const modules = [ + const modules = /** @type {StatsModule[]} */ ([ ...(bundleStats.chunks?.map((chunk) => chunk.modules) || []), ...(bundleStats.modules || []), - ].filter(Boolean); + ]).filter(Boolean); return flatten(modules).filter((mod) => { // Filtering out Webpack's runtime modules as they don't have ids and can't be parsed (introduced in Webpack 5) if (isRuntimeModule(mod)) { return false; } + if (seenIds.has(mod.id)) { return false; } + seenIds.add(mod.id); + return true; }); } +/** @typedef {Record>} ChunkToInitialByEntrypoint */ + +/** + * @param {StatsCompilation} bundleStats bundle stats + * @returns {ChunkToInitialByEntrypoint} chunk to initial by entrypoint + */ function getChunkToInitialByEntrypoint(bundleStats) { if (bundleStats === null || bundleStats === undefined) { return {}; } + /** @type {ChunkToInitialByEntrypoint} */ const chunkToEntrypointInititalMap = {}; for (const entrypoint of Object.values(bundleStats.entrypoints || {})) { - for (const asset of entrypoint.assets) { + for (const asset of entrypoint.assets || []) { chunkToEntrypointInititalMap[asset.name] ??= {}; - chunkToEntrypointInititalMap[asset.name][entrypoint.name] = true; + chunkToEntrypointInititalMap[asset.name][ + /** @type {string} */ + (entrypoint.name) + ] = true; } } return chunkToEntrypointInititalMap; } -function isEntryModule(statModule) { - return statModule.depth === 0; +/** + * @param {StatsModule} statsModule stats modules + * @returns {boolean} true when entry module, otherwise false + */ +function isEntryModule(statsModule) { + return statsModule.depth === 0; } +/** + * @typedef {object} ViewerDataOptions + * @property {Logger} logger logger + * @property {CompressionAlgorithm} compressionAlgorithm compression algorithm + * @property {ExcludeAssets} excludeAssets exclude assets + */ + +/** @typedef {import("./tree/Module").ModuleChartData} ModuleChartData */ +/** @typedef {import("./tree/ContentModule").ContentModuleChartData} ContentModuleChartData */ +/** @typedef {import("./tree/ConcatenatedModule").ConcatenatedModuleChartData} ConcatenatedModuleChartData */ +/** @typedef {import("./tree/ContentFolder").ContentFolderChartData} ContentFolderChartData */ +/** @typedef {import("./tree/Folder").FolderChartData} FolderChartData */ + +/** + * @typedef {object} ChartDataItem + * @property {string} label label + * @property {true} isAsset true when is asset, otherwise false + * @property {number} statSize stat size + * @property {number | undefined} parsedSize stat size + * @property {number | undefined} gzipSize gzip size + * @property {number | undefined} brotliSize brotli size + * @property {number | undefined} zstdSize zstd size + * @property {(ModuleChartData | ContentModuleChartData | ConcatenatedModuleChartData | ContentFolderChartData | FolderChartData)[]} groups groups + * @property {Record} isInitialByEntrypoint record with initial entrypoints + */ + +/** + * @typedef {ChartDataItem[]} ChartData + */ + +/** + * @param {StatsCompilation} bundleStats bundle stats + * @param {string | null} bundleDir bundle dir + * @param {ViewerDataOptions=} opts options + * @returns {ChartData} chart data + */ function getViewerData(bundleStats, bundleDir, opts) { const { logger = new Logger(), - compressionAlgorithm, + compressionAlgorithm = "gzip", excludeAssets = null, } = opts || {}; @@ -134,17 +233,19 @@ function getViewerData(bundleStats, bundleDir, opts) { // Sometimes if there are additional child chunks produced add them as child assets, // leave the 1st one as that is considered the 'root' asset. for (let i = 1; i < children.length; i++) { - for (const asset of children[i].assets) { + for (const asset of children[i].assets || []) { asset.isChild = true; - bundleStats.assets.push(asset); + /** @type {StatsAsset[]} */ + (bundleStats.assets).push(asset); } } } else if (bundleStats.children && bundleStats.children.length > 0) { // Sometimes if there are additional child chunks produced add them as child assets for (const child of bundleStats.children) { - for (const asset of child.assets) { + for (const asset of child.assets || []) { asset.isChild = true; - bundleStats.assets.push(asset); + /** @type {StatsAsset[]} */ + (bundleStats.assets).push(asset); } } } @@ -162,13 +263,16 @@ function getViewerData(bundleStats, bundleDir, opts) { return ( FILENAME_EXTENSIONS.test(asset.name) && + asset.chunks && asset.chunks.length > 0 && isAssetIncluded(asset.name) ); }); // Trying to parse bundle assets and get real module sizes if `bundleDir` is provided + /** @type {Record | null} */ let bundlesSources = null; + /** @type {Record | null} */ let parsedModules = null; if (bundleDir) { @@ -184,7 +288,10 @@ function getViewerData(bundleStats, bundleDir, opts) { sourceType: statAsset.info.javascriptModule ? "module" : "script", }); } catch (err) { - const msg = err.code === "ENOENT" ? "no such file" : err.message; + const msg = + /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT" + ? "no such file" + : /** @type {Error} */ (err).message; logger.warn(`Error parsing bundle asset "${assetFile}": ${msg}`, { cause: err, }); @@ -207,15 +314,21 @@ function getViewerData(bundleStats, bundleDir, opts) { } } + /** @typedef {{ size: number, parsedSize?: number, gzipSize?: number, brotliSize?: number, zstdSize?: number, modules: StatsModule[], tree: Folder }} Asset */ + const assets = bundleStats.assets.reduce((result, statAsset) => { // If asset is a childAsset, then calculate appropriate bundle modules by looking through stats.children const assetBundles = statAsset.isChild ? getChildAssetBundles(bundleStats, statAsset.name) : bundleStats; - const modules = assetBundles ? getBundleModules(assetBundles) : []; - const asset = (result[statAsset.name] = { + /** @type {StatsModule[]} */ + const modules = assetBundles + ? // @ts-expect-error TODO looks like we have a bug with child compilation parsing, need to add test cases + getBundleModules(assetBundles) + : []; + const asset = (result[statAsset.name] = /** @type {Asset} */ ({ size: statAsset.size, - }); + })); const assetSources = bundlesSources && Object.hasOwn(bundlesSources, statAsset.name) ? bundlesSources[statAsset.name] @@ -223,31 +336,39 @@ function getViewerData(bundleStats, bundleDir, opts) { if (assetSources) { asset.parsedSize = Buffer.byteLength(assetSources.src); + if (compressionAlgorithm === "gzip") { asset.gzipSize = getCompressedSize("gzip", assetSources.src); } + if (compressionAlgorithm === "brotli") { asset.brotliSize = getCompressedSize("brotli", assetSources.src); } + if (compressionAlgorithm === "zstd") { asset.zstdSize = getCompressedSize("zstd", assetSources.src); } } // Picking modules from current bundle script + /** @type {StatsModule[]} */ let assetModules = (modules || []).filter((statModule) => assetHasModule(statAsset, statModule), ); // Adding parsed sources if (parsedModules) { + /** @type {StatsModule[]} */ const unparsedEntryModules = []; - for (const statModule of assetModules) { - if (parsedModules[statModule.id]) { - statModule.parsedSrc = parsedModules[statModule.id]; - } else if (isEntryModule(statModule)) { - unparsedEntryModules.push(statModule); + for (const statsModule of assetModules) { + if ( + typeof statsModule.id !== "undefined" && + parsedModules[statsModule.id] + ) { + statsModule.parsedSrc = parsedModules[statsModule.id]; + } else if (isEntryModule(statsModule)) { + unparsedEntryModules.push(statsModule); } } @@ -269,7 +390,8 @@ function getViewerData(bundleStats, bundleDir, opts) { name: "./entry modules", modules: unparsedEntryModules, size: unparsedEntryModules.reduce( - (totalSize, module) => totalSize + module.size, + (totalSize, module) => + totalSize + /** @type {number} */ (module.size), 0, ), parsedSrc: assetSources.runtimeSrc, @@ -280,10 +402,12 @@ function getViewerData(bundleStats, bundleDir, opts) { asset.modules = assetModules; asset.tree = createModulesTree(asset.modules, { compressionAlgorithm }); + return result; - }, {}); + }, /** @type {Record} */ ({})); const chunkToInitialByEntrypoint = getChunkToInitialByEntrypoint(bundleStats); + return Object.entries(assets).map(([filename, asset]) => ({ label: filename, isAsset: true, @@ -301,6 +425,10 @@ function getViewerData(bundleStats, bundleDir, opts) { })); } +/** + * @param {string} filename filename + * @returns {Promise} result + */ function readStatsFromFile(filename) { return parseChunked(fs.createReadStream(filename, { encoding: "utf8" })); } diff --git a/src/bin/analyzer.js b/src/bin/analyzer.js index 724e3e55..819f821f 100755 --- a/src/bin/analyzer.js +++ b/src/bin/analyzer.js @@ -16,11 +16,20 @@ const COMPRESSION_ALGORITHMS = new Set( isZstdSupported ? ["gzip", "brotli", "zstd"] : ["gzip", "brotli"], ); +/** + * @param {string} str string + * @returns {string} break with string + */ function br(str) { return `\n${" ".repeat(32)}${str}`; } +/** + * @template T + * @returns {(val: T) => T[]} array + */ function array() { + /** @type {T[]} */ const arr = []; return (val) => { arr.push(val); @@ -56,7 +65,7 @@ const program = commanderProgram .option( "-p, --port ", "Port that will be used in `server` mode to start HTTP server.", - 8888, + "8888", ) .option( "-r, --report ", @@ -117,6 +126,9 @@ if (typeof reportTitle === "undefined") { reportTitle = utils.defaultTitle; } +/** + * @param {string} error error message + */ function showHelp(error) { if (error) console.log(`\n ${magenta(error)}\n`); program.outputHelp(); @@ -152,6 +164,10 @@ bundleStatsFile = resolve(bundleStatsFile); if (!bundleDir) bundleDir = dirname(bundleStatsFile); +/** + * @param {string} bundleStatsFile bundle stats file + * @returns {Promise} + */ async function parseAndAnalyse(bundleStatsFile) { try { const bundleStats = await analyzer.readStatsFromFile(bundleStatsFile); @@ -192,7 +208,7 @@ async function parseAndAnalyse(bundleStatsFile) { logger.error( `Couldn't read webpack bundle stats from "${bundleStatsFile}":\n${err}`, ); - logger.debug(err.stack); + logger.debug(/** @type {Error} */ (err).stack); process.exit(1); } } diff --git a/src/parseUtils.js b/src/parseUtils.js index 3fabbd56..4a6918aa 100644 --- a/src/parseUtils.js +++ b/src/parseUtils.js @@ -1,20 +1,43 @@ +/** @typedef {import("acorn").Node} Node */ +/** @typedef {import("acorn").CallExpression} CallExpression */ +/** @typedef {import("acorn").ExpressionStatement} ExpressionStatement */ +/** @typedef {import("acorn").Expression} Expression */ +/** @typedef {import("acorn").SpreadElement} SpreadElement */ + const fs = require("node:fs"); const acorn = require("acorn"); const walk = require("acorn-walk"); +/** + * @param {Expression} node node + * @returns {boolean} true when id is numeric, otherwise false + */ function isNumericId(node) { return ( - node.type === "Literal" && Number.isInteger(node.value) && node.value >= 0 + node.type === "Literal" && + node.value !== null && + node.value !== undefined && + Number.isInteger(node.value) && + /** @type {number} */ (node.value) >= 0 ); } +/** + * @param {Expression | SpreadElement | null} node node + * @returns {boolean} true when module id, otherwise false + */ function isModuleId(node) { return ( + node !== null && node.type === "Literal" && (isNumericId(node) || typeof node.value === "string") ); } +/** + * @param {Expression | SpreadElement} node node + * @returns {boolean} true when module wrapper, otherwise false + */ function isModuleWrapper(node) { return ( // It's an anonymous function expression that wraps module @@ -30,15 +53,28 @@ function isModuleWrapper(node) { ); } +/** + * @param {Expression | SpreadElement | null} node node + * @returns {boolean} true when module hash, otherwise false + */ function isModulesHash(node) { return ( + node !== null && node.type === "ObjectExpression" && - node.properties.map((node) => node.value).every(isModuleWrapper) + node.properties + .filter((property) => property.type !== "SpreadElement") + .map((node) => node.value) + .every(isModuleWrapper) ); } +/** + * @param {Expression | SpreadElement | null} node node + * @returns {boolean} true when module array, otherwise false + */ function isModulesArray(node) { return ( + node !== null && node.type === "ArrayExpression" && node.elements.every( (elem) => @@ -48,6 +84,10 @@ function isModulesArray(node) { ); } +/** + * @param {Expression | SpreadElement | null} node node + * @returns {boolean} true when simple modules list, otherwise false + */ function isSimpleModulesList(node) { return ( // Modules are contained in hash. Keys are module ids. @@ -57,11 +97,16 @@ function isSimpleModulesList(node) { ); } +/** + * @param {Expression | SpreadElement | null} node node + * @returns {boolean} true when optimized modules array, otherwise false + */ function isOptimizedModulesArray(node) { // Checking whether modules are contained in `Array().concat(...modules)` array: // https://github.com/webpack/webpack/blob/v1.14.0/lib/Template.js#L91 // The `` + array indexes are module ids return ( + node !== null && node.type === "CallExpression" && node.callee.type === "MemberExpression" && // Make sure the object called is `Array()` @@ -69,6 +114,7 @@ function isOptimizedModulesArray(node) { node.callee.object.callee.type === "Identifier" && node.callee.object.callee.name === "Array" && node.callee.object.arguments.length === 1 && + node.callee.object.arguments[0].type !== "SpreadElement" && isNumericId(node.callee.object.arguments[0]) && // Make sure the property X called for `Array().X` is `concat` node.callee.property.type === "Identifier" && @@ -79,6 +125,10 @@ function isOptimizedModulesArray(node) { ); } +/** + * @param {Expression | SpreadElement | null} node node + * @returns {boolean} true when modules list, otherwise false + */ function isModulesList(node) { return ( isSimpleModulesList(node) || @@ -87,6 +137,12 @@ function isModulesList(node) { ); } +/** @typedef {{ start: number, end: number }} Location */ + +/** + * @param {Node} node node + * @returns {Location} location + */ function getModuleLocation(node) { return { start: node.start, @@ -94,44 +150,74 @@ function getModuleLocation(node) { }; } +/** @typedef {Record} ModulesLocations */ + +/** + * @param {Expression | SpreadElement} node node + * @returns {ModulesLocations} modules locations + */ function getModulesLocations(node) { if (node.type === "ObjectExpression") { // Modules hash const modulesNodes = node.properties; return modulesNodes.reduce((result, moduleNode) => { - const moduleId = moduleNode.key.name || moduleNode.key.value; + if (moduleNode.type !== "Property") { + return result; + } + + const moduleId = + moduleNode.key.type === "Identifier" + ? moduleNode.key.name + : // @ts-expect-error need verify why we need it, tests not cover it case + moduleNode.key.value; + + if (moduleId === "undefined") { + return result; + } result[moduleId] = getModuleLocation(moduleNode.value); + return result; - }, {}); + }, /** @type {ModulesLocations} */ ({})); } const isOptimizedArray = node.type === "CallExpression"; if (node.type === "ArrayExpression" || isOptimizedArray) { // Modules array or optimized array - const minId = isOptimizedArray - ? // Get the [minId] value from the Array() call first argument literal value - node.callee.object.arguments[0].value - : // `0` for simple array - 0; + const minId = + isOptimizedArray && + node.callee.type === "MemberExpression" && + node.callee.object.type === "CallExpression" && + node.callee.object.arguments[0].type === "Literal" + ? // Get the [minId] value from the Array() call first argument literal value + /** @type {number} */ (node.callee.object.arguments[0].value) + : // `0` for simple array + 0; const modulesNodes = isOptimizedArray ? // The modules reside in the `concat()` function call arguments - node.arguments[0].elements + node.arguments[0].type === "ArrayExpression" + ? node.arguments[0].elements + : [] : node.elements; return modulesNodes.reduce((result, moduleNode, i) => { if (moduleNode) { result[i + minId] = getModuleLocation(moduleNode); } + return result; - }, {}); + }, /** @type {ModulesLocations} */ ({})); } return {}; } +/** + * @param {ExpressionStatement} node node + * @returns {boolean} true when IIFE, otherwise false + */ function isIIFE(node) { return ( node.type === "ExpressionStatement" && @@ -141,24 +227,45 @@ function isIIFE(node) { ); } +/** + * @param {ExpressionStatement} node node + * @returns {Expression} IIFE call expression + */ function getIIFECallExpression(node) { if (node.expression.type === "UnaryExpression") { return node.expression.argument; } + return node.expression; } +/** + * @param {Expression} node node + * @returns {boolean} true when chunks ids, otherwose false + */ function isChunkIds(node) { // Array of numeric or string ids. Chunk IDs are strings when NamedChunksPlugin is used return node.type === "ArrayExpression" && node.elements.every(isModuleId); } +/** + * @param {(Expression | SpreadElement | null)[]} args arguments + * @returns {boolean} true when async chunk arguments, otherwise false + */ function mayBeAsyncChunkArguments(args) { - return args.length >= 2 && isChunkIds(args[0]); + return ( + args.length >= 2 && + args[0] !== null && + args[0].type !== "SpreadElement" && + isChunkIds(args[0]) + ); } /** * Returns bundle source except modules + * @param {string} content content + * @param {ModulesLocations | null} modulesLocations modules locations + * @returns {string} runtime code */ function getBundleRuntime(content, modulesLocations) { const sortedLocations = Object.values(modulesLocations || {}).toSorted( @@ -176,11 +283,16 @@ function getBundleRuntime(content, modulesLocations) { return result + content.slice(lastIndex); } +/** + * @param {CallExpression} node node + * @returns {boolean} true when is async chunk push expression, otheriwse false + */ function isAsyncChunkPushExpression(node) { const { callee, arguments: args } = node; return ( callee.type === "MemberExpression" && + callee.property.type === "Identifier" && callee.property.name === "push" && callee.object.type === "AssignmentExpression" && args.length === 1 && @@ -190,6 +302,10 @@ function isAsyncChunkPushExpression(node) { ); } +/** + * @param {CallExpression} node node + * @returns {boolean} true when is async web worker, otherwise false + */ function isAsyncWebWorkerChunkExpression(node) { const { callee, type, arguments: args } = node; @@ -197,23 +313,29 @@ function isAsyncWebWorkerChunkExpression(node) { type === "CallExpression" && callee.type === "MemberExpression" && args.length === 2 && + args[0].type !== "SpreadElement" && isChunkIds(args[0]) && isModulesList(args[1]) ); } +/** @typedef {Record} Modules */ + +/** + * @param {string} bundlePath bundle path + * @param {{ sourceType: "script" | "module" }} opts options + * @returns {{ modules: Modules, src: string, runtimeSrc: string }} parsed result + */ module.exports.parseBundle = function parseBundle(bundlePath, opts) { const { sourceType = "script" } = opts || {}; const content = fs.readFileSync(bundlePath, "utf8"); const ast = acorn.parse(content, { sourceType, - // I believe in a bright future of ECMAScript! - // Actually, it's set to `2050` to support the latest ECMAScript version that currently exists. - // Seems like `acorn` supports such weird option value. - ecmaVersion: 2050, + ecmaVersion: "latest", }); + /** @type {{ locations: ModulesLocations | null, expressionStatementDepth: number }} */ const walkState = { locations: null, expressionStatementDepth: 0, @@ -234,10 +356,14 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { const fn = getIIFECallExpression(node); if ( + fn.type === "CallExpression" && // It should not contain neither arguments fn.arguments.length === 0 && + (fn.callee.type === "FunctionExpression" || + fn.callee.type === "ArrowFunctionExpression") && // ...nor parameters - fn.callee.params.length === 0 + fn.callee.params.length === 0 && + fn.callee.body.type === "BlockStatement" ) { // Modules are stored in the very first variable declaration as hash const firstVariableDeclaration = fn.callee.body.body.find( @@ -274,9 +400,12 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { if ( left && + left.type === "MemberExpression" && left.object && + left.object.type === "Identifier" && left.object.name === "exports" && left.property && + left.property.type === "Identifier" && left.property.name === "modules" && isModulesHash(right) ) { @@ -308,6 +437,7 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { if ( node.callee.type === "Identifier" && mayBeAsyncChunkArguments(args) && + args[1].type !== "SpreadElement" && isModulesList(args[1]) ) { state.locations = getModulesLocations(args[1]); @@ -317,7 +447,11 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { // Async Webpack v4 chunk without webpack loader. // (window.webpackJsonp=window.webpackJsonp||[]).push([[], , ...]); // As function name may be changed with `output.jsonpFunction` option we can't rely on it's default name. - if (isAsyncChunkPushExpression(node)) { + if ( + isAsyncChunkPushExpression(node) && + args[0].type === "ArrayExpression" && + args[0].elements[1] + ) { state.locations = getModulesLocations(args[0].elements[1]); return; } @@ -338,6 +472,7 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { }, }); + /** @type {Modules} */ const modules = {}; if (walkState.locations) { diff --git a/src/sizeUtils.js b/src/sizeUtils.js index c7d1f07c..f52979e4 100644 --- a/src/sizeUtils.js +++ b/src/sizeUtils.js @@ -2,21 +2,26 @@ import zlib from "node:zlib"; export const isZstdSupported = "createZstdCompress" in zlib; -export function getCompressedSize(compressionAlgorithm, input) { - if (compressionAlgorithm === "gzip") { +/** @typedef {"gzip" | "brotli" | "zstd"} Algorithm */ + +/** + * @param {Algorithm} algorithm compression algorithm + * @param {string} input input + * @returns {number} compressed size + */ +export function getCompressedSize(algorithm, input) { + if (algorithm === "gzip") { return zlib.gzipSync(input, { level: 9 }).length; } - if (compressionAlgorithm === "brotli") { + if (algorithm === "brotli") { return zlib.brotliCompressSync(input).length; } - if (compressionAlgorithm === "zstd" && isZstdSupported) { + if (algorithm === "zstd" && isZstdSupported) { // eslint-disable-next-line n/no-unsupported-features/node-builtins return zlib.zstdCompressSync(input).length; } - throw new Error( - `Unsupported compression algorithm: ${compressionAlgorithm}.`, - ); + throw new Error(`Unsupported compression algorithm: ${algorithm}.`); } diff --git a/src/statsUtils.js b/src/statsUtils.js index 5bac3039..63e8d4c9 100644 --- a/src/statsUtils.js +++ b/src/statsUtils.js @@ -1,7 +1,13 @@ const { createWriteStream } = require("node:fs"); const { Readable } = require("node:stream"); +/** @typedef {import("./BundleAnalyzerPlugin").EXPECTED_ANY} EXPECTED_ANY */ +/** @typedef {import("webpack").StatsCompilation} StatsCompilation */ + class StatsSerializeStream extends Readable { + /** + * @param {StatsCompilation} stats stats + */ constructor(stats) { super(); this._indentLevel = 0; @@ -27,6 +33,11 @@ class StatsSerializeStream extends Readable { } } + /** + * @param {EXPECTED_ANY} obj obj + * @returns {Generator} stringified result + * @private + */ *_stringify(obj) { if ( typeof obj === "string" || @@ -74,6 +85,11 @@ class StatsSerializeStream extends Readable { } } +/** + * @param {StatsCompilation} stats stats + * @param {string} filepath filepath file path + * @returns {Promise} + */ async function writeStats(stats, filepath) { return new Promise((resolve, reject) => { new StatsSerializeStream(stats) diff --git a/src/template.js b/src/template.js index a9413f1e..098dcd84 100644 --- a/src/template.js +++ b/src/template.js @@ -6,13 +6,26 @@ const { escape } = require("html-escaper"); const projectRoot = path.resolve(__dirname, ".."); const assetsRoot = path.join(projectRoot, "public"); +/** @typedef {import("./BundleAnalyzerPlugin").EXPECTED_ANY} EXPECTED_ANY */ +/** @typedef {import("./BundleAnalyzerPlugin").Mode} Mode */ +/** @typedef {import("./BundleAnalyzerPlugin").Sizes} Sizes */ +/** @typedef {import("./BundleAnalyzerPlugin").CompressionAlgorithm} CompressionAlgorithm */ +/** @typedef {import("./analyzer").ChartData} ChartData */ +/** @typedef {import("./viewer").Entrypoints} Entrypoints */ + /** * Escapes `<` characters in JSON to safely use it in ``; } +/** + * @typedef {object} ViewerOptions + * @property {string} title title + * @property {boolean} enableWebSocket true when need to enable, otherwise false + * @property {ChartData} chartData chart data + * @property {Entrypoints} entrypoints entrypoints + * @property {Sizes} defaultSizes default sizes + * @property {CompressionAlgorithm} compressionAlgorithm compression algorithm + * @property {Mode} mode mode + */ + +/** + * @param {ViewerOptions} options viewer Options + * @returns {string} content for viewer + */ function renderViewer({ title, enableWebSocket, @@ -46,7 +85,7 @@ function renderViewer({ defaultSizes, compressionAlgorithm, mode, -} = {}) { +}) { return html` diff --git a/src/tree/BaseFolder.js b/src/tree/BaseFolder.js index 45f32691..dec093f8 100644 --- a/src/tree/BaseFolder.js +++ b/src/tree/BaseFolder.js @@ -1,31 +1,76 @@ import Node from "./Node.js"; +/** @typedef {import("./Folder").default} Folder */ +/** @typedef {import("./Module").default} Module */ +/** @typedef {import("./Module").ModuleChartData} ModuleChartData */ +/** @typedef {import("./ConcatenatedModule").default} ConcatenatedModule */ +/** @typedef {import("./ContentModule").default} ContentModule */ +/** @typedef {import("./ContentFolder").default} ContentFolder */ +/** @typedef {import("./ContentFolder").ContentFolderChartData} ContentFolderChartData */ +/** @typedef {import("./Folder").FolderChartData} FolderChartData */ + +/** + * @typedef {object} BaseFolderChartData + * @property {string} label label + * @property {string} path path + * @property {number} statSize stat size + * @property {(FolderChartData | ModuleChartData | ContentFolderChartData)[]} groups groups + */ + +/** @typedef {Module | ContentModule | ConcatenatedModule | ContentFolder | Folder} Children */ + export default class BaseFolder extends Node { + /** + * @param {string} name name + * @param {Node=} parent parent + */ constructor(name, parent) { super(name, parent); + /** @type {Record} */ this.children = Object.create(null); } + /** + * @returns {string} src + */ get src() { if (!Object.hasOwn(this, "_src")) { - this._src = this.walk((node, src) => (src += node.src || ""), "", false); + this._src = this.walk( + (node, src) => (src += node.src || ""), + /** @type {string} */ (""), + false, + ); } - return this._src; + return /** @type {string} */ (this._src); } + /** + * @returns {number} size + */ get size() { if (!Object.hasOwn(this, "_size")) { - this._size = this.walk((node, size) => size + node.size, 0, false); + this._size = this.walk( + (node, size) => size + node.size, + /** @type {number} */ (0), + false, + ); } - return this._size; + return /** @type {number} */ (this._size); } + /** + * @param {string} name name + * @returns {Children} child + */ getChild(name) { return this.children[name]; } + /** + * @param {Module | ContentModule | ConcatenatedModule} module module + */ addChildModule(module) { const { name } = module; const currentChild = this.children[name]; @@ -47,6 +92,10 @@ export default class BaseFolder extends Node { delete this._src; } + /** + * @param {ContentFolder | Folder} folder folder + * @returns {ContentFolder | Folder} folder + */ addChildFolder(folder) { folder.parent = this; this.children[folder.name] = folder; @@ -56,9 +105,20 @@ export default class BaseFolder extends Node { return folder; } - walk(walker, state = {}, deep = true) { + /** + * @template T + * @param {(node: Children, state: T, stop: (state: T) => void) => T} walker walker function + * @param {T} state state state + * @param {boolean | ((state: T) => T)=} deep true when need to deep walk, otherwise false + * @returns {T} state + */ + walk(walker, state = /** @type T */ ({}), deep = true) { let stopped = false; + /** + * @param {T} finalState final state + * @returns {T} final state + */ function stop(finalState) { stopped = true; return finalState; @@ -66,11 +126,11 @@ export default class BaseFolder extends Node { for (const child of Object.values(this.children)) { state = - deep && child.walk - ? child.walk(walker, state, stop) + deep && /** @type {BaseFolder} */ (child).walk + ? /** @type {BaseFolder} */ (child).walk(walker, state, stop) : walker(child, state, stop); - if (stopped) return false; + if (stopped) return /** @type {T} */ (false); } return state; @@ -86,7 +146,7 @@ export default class BaseFolder extends Node { if (onlyChild instanceof this.constructor) { this.name += `/${onlyChild.name}`; - this.children = onlyChild.children; + this.children = /** @type {BaseFolder} */ (onlyChild).children; } else { break; } @@ -94,18 +154,27 @@ export default class BaseFolder extends Node { } this.walk( - (child) => { + (child, state) => { child.parent = this; - if (child.mergeNestedFolders) { - child.mergeNestedFolders(); + if ( + /** @type {Folder | ContentFolder | ConcatenatedModule} */ + (child).mergeNestedFolders + ) { + /** @type {Folder | ContentFolder | ConcatenatedModule} */ + (child).mergeNestedFolders(); } + + return state; }, null, false, ); } + /** + * @returns {BaseFolderChartData} base folder chart data + */ toChartData() { return { label: this.name, diff --git a/src/tree/ConcatenatedModule.js b/src/tree/ConcatenatedModule.js index 7abc66a1..bf85b696 100644 --- a/src/tree/ConcatenatedModule.js +++ b/src/tree/ConcatenatedModule.js @@ -3,10 +3,35 @@ import ContentModule from "./ContentModule.js"; import Module from "./Module.js"; import { getModulePathParts } from "./utils.js"; +/** @typedef {import("webpack").StatsModule} StatsModule */ +/** @typedef {import("./Node").default} NodeType */ +/** @typedef {import("./Module").ModuleChartData} ModuleChartData */ +/** @typedef {import("./Module").SizeType} SizeType */ +/** @typedef {import("./Folder").default} Folder */ +/** @typedef {import("./BaseFolder").Children} Children */ +/** @typedef {import("./ContentFolder").ContentFolderChartData} ContentFolderChartData */ +/** @typedef {import("./ContentModule").ContentModuleChartData} ContentModuleChartData */ +/** @typedef {import("../sizeUtils").Algorithm} CompressionAlgorithm */ + +/** + * @typedef {object} OwnConcatenatedModuleChartData + * @property {boolean} concatenated true when concatenated, otherwise false + * @property {(ConcatenatedModuleChartData | ContentFolderChartData | ContentModuleChartData)[]} groups groups + */ + +/** @typedef {ModuleChartData & OwnConcatenatedModuleChartData} ConcatenatedModuleChartData */ + export default class ConcatenatedModule extends Module { + /** + * @param {string} name name + * @param {StatsModule} data data + * @param {NodeType} parent parent + * @param {{ compressionAlgorithm: CompressionAlgorithm }} opts options + */ constructor(name, data, parent, opts) { super(name, data, parent, opts); this.name += " (concatenated)"; + /** @type {Record} */ this.children = Object.create(null); this.fillContentModules(); } @@ -27,20 +52,30 @@ export default class ConcatenatedModule extends Module { return this.getZstdSize() ?? this.getEstimatedSize("zstdSize"); } + /** + * @param {SizeType} sizeType size type + * @returns {number | undefined} size + */ getEstimatedSize(sizeType) { - const parentModuleSize = this.parent[sizeType]; + const parentModuleSize = /** @type {Folder} */ (this.parent)[sizeType]; if (parentModuleSize !== undefined) { - return Math.floor((this.size / this.parent.size) * parentModuleSize); + return Math.floor( + (this.size / /** @type {Folder} */ (this.parent).size) * + parentModuleSize, + ); } } fillContentModules() { - for (const moduleData of this.data.modules) { + for (const moduleData of this.data.modules || []) { this.addContentModule(moduleData); } } + /** + * @param {StatsModule} moduleData module data + */ addContentModule(moduleData) { const pathParts = getModulePathParts(moduleData); @@ -52,9 +87,11 @@ export default class ConcatenatedModule extends Module { pathParts.slice(0, -1), pathParts[pathParts.length - 1], ]; + /** @type {ConcatenatedModule | ContentFolder} */ let currentFolder = this; for (const folderName of folders) { + /** @type {Children} */ let childFolder = currentFolder.getChild(folderName); if (!childFolder) { @@ -63,7 +100,9 @@ export default class ConcatenatedModule extends Module { ); } - currentFolder = childFolder; + currentFolder = + /** @type {ConcatenatedModule | ContentFolder} */ + (childFolder); } const ModuleConstructor = moduleData.modules @@ -73,15 +112,26 @@ export default class ConcatenatedModule extends Module { currentFolder.addChildModule(module); } + /** + * @param {string} name name + * @returns {ConcatenatedModule | ContentModule | ContentFolder} child folder + */ getChild(name) { return this.children[name]; } + /** + * @param {ConcatenatedModule | ContentModule} module child module + */ addChildModule(module) { module.parent = this; this.children[module.name] = module; } + /** + * @param {ContentFolder} folder child folder + * @returns {ContentFolder} child folder + */ addChildFolder(folder) { folder.parent = this; this.children[folder.name] = folder; @@ -90,12 +140,19 @@ export default class ConcatenatedModule extends Module { mergeNestedFolders() { for (const child of Object.values(this.children)) { - if (child.mergeNestedFolders) { - child.mergeNestedFolders(); + if ( + /** @type {Folder | ContentFolder | ConcatenatedModule} */ + (child).mergeNestedFolders + ) { + /** @type {Folder | ContentFolder | ConcatenatedModule} */ + (child).mergeNestedFolders(); } } } + /** + * @returns {ConcatenatedModuleChartData} chart data + */ toChartData() { return { ...super.toChartData(), diff --git a/src/tree/ContentFolder.js b/src/tree/ContentFolder.js index d8e48e19..81537afd 100644 --- a/src/tree/ContentFolder.js +++ b/src/tree/ContentFolder.js @@ -1,6 +1,27 @@ import BaseFolder from "./BaseFolder.js"; +/** @typedef {import("./Node").default} Node */ +/** @typedef {import("./ConcatenatedModule").default} ConcatenatedModule */ +/** @typedef {import("./BaseFolder").BaseFolderChartData} BaseFolderChartData */ +/** @typedef {import("./Module").SizeType} SizeType */ + +/** + * @typedef {object} OwnContentFolderChartData + * @property {number | undefined} parsedSize parsed size + * @property {number | undefined} gzipSize gzip size + * @property {number | undefined} brotliSize brotli size + * @property {number | undefined} zstdSize zstd size + * @property {boolean} inaccurateSizes true when inaccurate sizes, otherwise false + */ + +/** @typedef {BaseFolderChartData & OwnContentFolderChartData} ContentFolderChartData */ + export default class ContentFolder extends BaseFolder { + /** + * @param {string} name name + * @param {ConcatenatedModule} ownerModule owner module + * @param {Node=} parent v + */ constructor(name, ownerModule, parent) { super(name, parent); this.ownerModule = ownerModule; @@ -22,6 +43,10 @@ export default class ContentFolder extends BaseFolder { return this.getSize("zstdSize"); } + /** + * @param {SizeType} sizeType size type + * @returns {number | undefined} size + */ getSize(sizeType) { const ownerModuleSize = this.ownerModule[sizeType]; @@ -30,6 +55,9 @@ export default class ContentFolder extends BaseFolder { } } + /** + * @returns {ContentFolderChartData} chart data + */ toChartData() { return { ...super.toChartData(), diff --git a/src/tree/ContentModule.js b/src/tree/ContentModule.js index 3d189de4..d8b7c415 100644 --- a/src/tree/ContentModule.js +++ b/src/tree/ContentModule.js @@ -1,8 +1,29 @@ import Module from "./Module.js"; +/** @typedef {import("webpack").StatsModule} StatsModule */ +/** @typedef {import("./Node").default} NodeType */ +/** @typedef {import("./Module").ModuleChartData} ModuleChartData */ +/** @typedef {import("./Module").ModuleOptions} ModuleOptions */ +/** @typedef {import("./Module").SizeType} SizeType */ +/** @typedef {import("./ConcatenatedModule").default} ConcatenatedModule */ + +/** + * @typedef {object} OwnContentModuleChartData + * @property {boolean} inaccurateSizes true when inaccurate sizes, otherwise false + */ + +/** @typedef {ModuleChartData & OwnContentModuleChartData} ContentModuleChartData */ + export default class ContentModule extends Module { - constructor(name, data, ownerModule, parent) { - super(name, data, parent); + /** + * @param {string} name name + * @param {StatsModule} data data + * @param {ConcatenatedModule} ownerModule owner module + * @param {ModuleOptions} opts options + */ + constructor(name, data, ownerModule, opts) { + super(name, data, undefined, opts); + /** @type {ConcatenatedModule} */ this.ownerModule = ownerModule; } @@ -22,6 +43,10 @@ export default class ContentModule extends Module { return this.getSize("zstdSize"); } + /** + * @param {SizeType} sizeType size type + * @returns {number | undefined} size + */ getSize(sizeType) { const ownerModuleSize = this.ownerModule[sizeType]; @@ -30,6 +55,9 @@ export default class ContentModule extends Module { } } + /** + * @returns {ContentModuleChartData} chart data + */ toChartData() { return { ...super.toChartData(), diff --git a/src/tree/Folder.js b/src/tree/Folder.js index 8bdf23a4..bcc69b59 100644 --- a/src/tree/Folder.js +++ b/src/tree/Folder.js @@ -4,10 +4,36 @@ import ConcatenatedModule from "./ConcatenatedModule.js"; import Module from "./Module.js"; import { getModulePathParts } from "./utils.js"; +/** @typedef {import("webpack").StatsModule} StatsModule */ +/** @typedef {import("../analyzer").AnalyzerOptions} AnalyzerOptions */ +/** @typedef {import("../analyzer").CompressionAlgorithm} CompressionAlgorithm */ +/** @typedef {import("./BaseFolder").BaseFolderChartData} BaseFolderChartData */ + +/** + * @typedef {object} OwnFolderChartData + * @property {number} parsedSize parsed size + * @property {number | undefined} gzipSize gzip size + * @property {number | undefined} brotliSize brotli size + * @property {number | undefined} zstdSize zstd size + */ + +/** @typedef {BaseFolderChartData & OwnFolderChartData} FolderChartData */ + export default class Folder extends BaseFolder { + /** + * @param {string} name name + * @param {AnalyzerOptions} opts options + */ constructor(name, opts) { super(name); + /** @type {AnalyzerOptions} */ this.opts = opts; + /** @type {number | undefined} */ + this._gzipSize = undefined; + /** @type {number | undefined} */ + this._brotliSize = undefined; + /** @type {number | undefined} */ + this._zstdSize = undefined; } get parsedSize() { @@ -32,8 +58,14 @@ export default class Folder extends BaseFolder { : undefined; } + /** + * @param {CompressionAlgorithm} compressionAlgorithm compression algorithm + * @returns {number | undefined} compressed size + */ getCompressedSize(compressionAlgorithm) { - const key = `_${compressionAlgorithm}Size`; + const key = + /** @type {`_${CompressionAlgorithm}Size`} */ + (`_${compressionAlgorithm}Size`); if (!Object.hasOwn(this, key)) { this[key] = this.src @@ -44,6 +76,9 @@ export default class Folder extends BaseFolder { return this[key]; } + /** + * @param {StatsModule} moduleData stats module + */ addModule(moduleData) { const pathParts = getModulePathParts(moduleData); @@ -55,6 +90,7 @@ export default class Folder extends BaseFolder { pathParts.slice(0, -1), pathParts[pathParts.length - 1], ]; + /** @type {BaseFolder} */ let currentFolder = this; for (const folderName of folders) { @@ -82,6 +118,9 @@ export default class Folder extends BaseFolder { currentFolder.addChildModule(module); } + /** + * @returns {FolderChartData} chart data + */ toChartData() { return { ...super.toChartData(), diff --git a/src/tree/Module.js b/src/tree/Module.js index a44f3d6a..7abae7c2 100644 --- a/src/tree/Module.js +++ b/src/tree/Module.js @@ -1,11 +1,44 @@ import { getCompressedSize } from "../sizeUtils.js"; import Node from "./Node.js"; +/** @typedef {import("webpack").StatsModule} StatsModule */ +/** @typedef {import("../sizeUtils").Algorithm} CompressionAlgorithm */ + +/** @typedef {{ compressionAlgorithm: CompressionAlgorithm }} ModuleOptions */ + +/** @typedef {"parsedSize" | "gzipSize" | "brotliSize" | "zstdSize"} SizeType */ + +/** + * @typedef {object} ModuleChartData + * @property {string | number | undefined} id id + * @property {string} label label + * @property {string} path path + * @property {number | undefined} statSize stat size + * @property {number | undefined} parsedSize parsed size + * @property {number | undefined} gzipSize gzip size + * @property {number | undefined} brotliSize brotli size + * @property {number | undefined} zstdSize zstd size + */ + export default class Module extends Node { + /** + * @param {string} name name + * @param {StatsModule} data data + * @param {Node | undefined} parent parent + * @param {ModuleOptions} opts options + */ constructor(name, data, parent, opts) { super(name, parent); + /** @type {StatsModule} */ this.data = data; + /** @type {ModuleOptions} */ this.opts = opts; + /** @type {number | undefined} */ + this._gzipSize = undefined; + /** @type {number | undefined} */ + this._brotliSize = undefined; + /** @type {number | undefined} */ + this._zstdSize = undefined; } get src() { @@ -19,8 +52,11 @@ export default class Module extends Node { delete this._zstdSize; } + /** + * @returns {number} size + */ get size() { - return this.data.size; + return /** @type {number} */ (this.data.size); } set size(value) { @@ -65,8 +101,14 @@ export default class Module extends Node { : undefined; } + /** + * @param {CompressionAlgorithm} compressionAlgorithm compression algorithm + * @returns {number | undefined} compressed size + */ getCompressedSize(compressionAlgorithm) { - const key = `_${compressionAlgorithm}Size`; + const key = + /** @type {`_${CompressionAlgorithm}Size`} */ + (`_${compressionAlgorithm}Size`); if (!(key in this)) { this[key] = this.src ? getCompressedSize(compressionAlgorithm, this.src) @@ -76,9 +118,13 @@ export default class Module extends Node { return this[key]; } + /** + * @param {StatsModule} data data + */ mergeData(data) { if (data.size) { - this.size += data.size; + /** @type {number} */ + (this.size) += data.size; } if (data.parsedSrc) { @@ -86,6 +132,9 @@ export default class Module extends Node { } } + /** + * @returns {ModuleChartData} module chart data + */ toChartData() { return { id: this.data.id, diff --git a/src/tree/Node.js b/src/tree/Node.js index efb05ed8..0a50f067 100644 --- a/src/tree/Node.js +++ b/src/tree/Node.js @@ -1,11 +1,19 @@ export default class Node { + /** + * @param {string} name name + * @param {Node=} parent parent + */ constructor(name, parent) { + /** @type {string} */ this.name = name; + /** @type {Node | undefined} */ this.parent = parent; } get path() { + /** @type {string[]} */ const path = []; + /** @type {Node | undefined} */ let node = this; while (node) { diff --git a/src/tree/utils.js b/src/tree/utils.js index 2cf29c07..83a2157d 100644 --- a/src/tree/utils.js +++ b/src/tree/utils.js @@ -1,10 +1,23 @@ const MULTI_MODULE_REGEXP = /^multi /u; +/** @typedef {import("webpack").StatsModule} StatsModule */ + +/** + * @param {StatsModule} moduleData moduleData + * @returns {string[] | null} module path parts + */ export function getModulePathParts(moduleData) { - if (MULTI_MODULE_REGEXP.test(moduleData.identifier)) { + if ( + moduleData.identifier && + MULTI_MODULE_REGEXP.test(moduleData.identifier) + ) { return [moduleData.identifier]; } + if (!moduleData.name) { + return null; + } + const loaders = moduleData.name.split("!"); // Removing loaders from module path: they're joined by `!` and the last part is a raw module path const parsedPath = loaders[loaders.length - 1] diff --git a/src/utils.js b/src/utils.js index 32bd5a8a..4f90decf 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,6 +1,13 @@ +/** @typedef {import("net").AddressInfo} AddressInfo */ +/** @typedef {import("webpack").StatsAsset} StatsAsset */ + const { inspect, types } = require("node:util"); const opener = require("opener"); +/** @typedef {import("./BundleAnalyzerPlugin").ExcludeAssets} ExcludeAssets */ +/** @typedef {import("./BundleAnalyzerPlugin").AnalyzerUrl} AnalyzerUrl */ +/** @typedef {import("./Logger")} Logger */ + const MONTHS = [ "Jan", "Feb", @@ -16,7 +23,12 @@ const MONTHS = [ "Dec", ]; +/** + * @param {ExcludeAssets} excludePatterns exclude patterns + * @returns {(asset: string) => boolean} function to filter + */ function createAssetsFilter(excludePatterns) { + /** @type {((asset: string) => void | boolean)[]} */ const excludeFunctions = ( Array.isArray(excludePatterns) ? excludePatterns : [excludePatterns] ) @@ -27,7 +39,13 @@ function createAssetsFilter(excludePatterns) { } if (types.isRegExp(pattern)) { - return (asset) => pattern.test(asset); + return ( + /** + * @param {string} asset asset + * @returns {boolean} true when need to exclude, otherwise false + */ + (asset) => pattern.test(asset) + ); } if (typeof pattern !== "function") { @@ -46,15 +64,16 @@ function createAssetsFilter(excludePatterns) { return () => true; } +/** @type {AnalyzerUrl} */ function defaultAnalyzerUrl(options) { const { listenHost, boundAddress } = options; - return `http://${listenHost}:${boundAddress.port}`; + return `http://${listenHost}:${/** @type {AddressInfo} */ (boundAddress).port}`; } /** - * @desc get string of current time - * format: dd/MMM HH:mm - * */ + * get string of current time, format: dd/MMM HH:mm + * @returns {string} default title + */ function defaultTitle() { const time = new Date(); const year = time.getFullYear(); @@ -70,6 +89,8 @@ function defaultTitle() { /** * Calls opener on a URI, but silently try / catches it. + * @param {string} uri URI + * @param {Logger} logger logger */ function open(uri, logger) { try { diff --git a/src/viewer.js b/src/viewer.js index 533bab61..2b372516 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -11,8 +11,23 @@ const analyzer = require("./analyzer"); const { renderViewer } = require("./template"); const { open } = require("./utils"); +/** @typedef {import("http").Server} Server */ +/** @typedef {import("ws").WebSocketServer} WebSocketServer */ +/** @typedef {import("webpack").StatsCompilation} StatsCompilation */ +/** @typedef {import("./BundleAnalyzerPlugin").Sizes} Sizes */ +/** @typedef {import("./BundleAnalyzerPlugin").CompressionAlgorithm} CompressionAlgorithm */ +/** @typedef {import("./BundleAnalyzerPlugin").ReportTitle} ReportTitle */ +/** @typedef {import("./BundleAnalyzerPlugin").AnalyzerUrl} AnalyzerUrl */ +/** @typedef {import("./BundleAnalyzerPlugin").ExcludeAssets} ExcludeAssets */ +/** @typedef {import("./analyzer").ViewerDataOptions} ViewerDataOptions */ +/** @typedef {import("./analyzer").ChartData} ChartData */ + const projectRoot = path.resolve(__dirname, ".."); +/** + * @param {string | (() => string)} reportTitle report title + * @returns {string} resolved title + */ function resolveTitle(reportTitle) { if (typeof reportTitle === "function") { return reportTitle(); @@ -21,6 +36,11 @@ function resolveTitle(reportTitle) { return reportTitle; } +/** + * @param {Sizes} defaultSizes default sizes + * @param {CompressionAlgorithm} compressionAlgorithm compression algorithm + * @returns {Sizes} default sizes + */ function resolveDefaultSizes(defaultSizes, compressionAlgorithm) { if (["gzip", "brotli", "zstd"].includes(defaultSizes)) { return compressionAlgorithm; @@ -29,6 +49,12 @@ function resolveDefaultSizes(defaultSizes, compressionAlgorithm) { return defaultSizes; } +/** @typedef {(string | undefined | null)[]} Entrypoints */ + +/** + * @param {StatsCompilation} bundleStats bundle stats + * @returns {Entrypoints} entrypoints + */ function getEntrypoints(bundleStats) { if ( bundleStats === null || @@ -37,20 +63,28 @@ function getEntrypoints(bundleStats) { ) { return []; } + return Object.values(bundleStats.entrypoints).map( (entrypoint) => entrypoint.name, ); } -function getChartData(analyzerOpts, ...args) { +/** + * @param {ViewerDataOptions} analyzerOpts analyzer options + * @param {StatsCompilation} bundleStats bundle stats + * @param {string | null} bundleDir bundle dir + * @returns {ChartData | null} chart data + */ +function getChartData(analyzerOpts, bundleStats, bundleDir) { + /** @type {ChartData | undefined | null} */ let chartData; const { logger } = analyzerOpts; try { - chartData = analyzer.getViewerData(...args, analyzerOpts); + chartData = analyzer.getViewerData(bundleStats, bundleDir, analyzerOpts); } catch (err) { logger.error(`Couldn't analyze webpack bundle:\n${err}`); - logger.debug(err.stack); + logger.debug(/** @type {Error} */ (err).stack); chartData = null; } @@ -67,6 +101,27 @@ function getChartData(analyzerOpts, ...args) { return chartData; } +/** + * @typedef {object} ServerOptions + * @property {number} port port + * @property {string} host host + * @property {boolean} openBrowser true when need to open browser, otherwise false + * @property {string | null} bundleDir bundle dir + * @property {Logger} logger logger + * @property {Sizes} defaultSizes default sizes + * @property {CompressionAlgorithm} compressionAlgorithm compression algorithm + * @property {ExcludeAssets | null} excludeAssets exclude assets + * @property {ReportTitle} reportTitle report title + * @property {AnalyzerUrl} analyzerUrl analyzer url + */ + +/** @typedef {{ ws: WebSocketServer, http: Server, updateChartData: (bundleStats: StatsCompilation) => void }} ViewerServerObj */ + +/** + * @param {StatsCompilation} bundleStats bundle stats + * @param {ServerOptions} opts options + * @returns {Promise} server + */ async function startServer(bundleStats, opts) { const { port = 8888, @@ -84,21 +139,23 @@ async function startServer(bundleStats, opts) { const analyzerOpts = { logger, excludeAssets, compressionAlgorithm }; let chartData = getChartData(analyzerOpts, bundleStats, bundleDir); - const entrypoints = getEntrypoints(bundleStats); - if (!chartData) return; + if (!chartData) { + throw new Error("Can't get chart data"); + } const sirvMiddleware = sirv(`${projectRoot}/public`, { // disables caching and traverse the file system on every request dev: true, }); + const entrypoints = getEntrypoints(bundleStats); const server = http.createServer((req, res) => { if (req.method === "GET" && req.url === "/") { const html = renderViewer({ mode: "server", title: resolveTitle(reportTitle), - chartData, + chartData: /** @type {ChartData} */ (chartData), entrypoints, defaultSizes: resolveDefaultSizes(defaultSizes, compressionAlgorithm), compressionAlgorithm, @@ -111,38 +168,46 @@ async function startServer(bundleStats, opts) { } }); - await new Promise((resolve) => { - server.listen(port, host, () => { - resolve(); + await new Promise( + /** + * @param {(value: void) => void} resolve resolve + */ + (resolve) => { + server.listen(port, host, () => { + resolve(); + + const url = analyzerUrl({ + listenPort: port, + listenHost: host, + boundAddress: server.address(), + }); + + logger.info( + `${bold("Webpack Bundle Analyzer")} is started at ${bold(url)}\n` + + `Use ${bold("Ctrl+C")} to close it`, + ); - const url = analyzerUrl({ - listenPort: port, - listenHost: host, - boundAddress: server.address(), + if (openBrowser) { + open(url, logger); + } }); - - logger.info( - `${bold("Webpack Bundle Analyzer")} is started at ${bold(url)}\n` + - `Use ${bold("Ctrl+C")} to close it`, - ); - - if (openBrowser) { - open(url, logger); - } - }); - }); + }, + ); const wss = new WebSocket.Server({ server }); wss.on("connection", (ws) => { ws.on("error", (err) => { // Ignore network errors like `ECONNRESET`, `EPIPE`, etc. - if (err.errno) return; + if (/** @type {NodeJS.ErrnoException} */ (err).errno) return; logger.info(err.message); }); }); + /** + * @param {StatsCompilation} bundleStats bundle stats + */ function updateChartData(bundleStats) { const newChartData = getChartData(analyzerOpts, bundleStats, bundleDir); @@ -169,6 +234,23 @@ async function startServer(bundleStats, opts) { }; } +/** + * @typedef {object} GenerateReportOptions + * @property {boolean} openBrowser true when need to open browser, otherwise false + * @property {string} reportFilename report filename + * @property {ReportTitle} reportTitle report title + * @property {string | null} bundleDir bundle dir + * @property {Logger} logger logger + * @property {Sizes} defaultSizes default sizes + * @property {CompressionAlgorithm} compressionAlgorithm compression algorithm + * @property {ExcludeAssets} excludeAssets exclude assets + */ + +/** + * @param {StatsCompilation} bundleStats bundle stats + * @param {GenerateReportOptions} opts opts + * @returns {Promise} + */ async function generateReport(bundleStats, opts) { const { openBrowser = true, @@ -216,6 +298,20 @@ async function generateReport(bundleStats, opts) { } } +/** + * @typedef {object} GenerateJSONReportOptions + * @property {string} reportFilename report filename + * @property {string | null} bundleDir bundle dir + * @property {Logger} logger logger + * @property {ExcludeAssets} excludeAssets exclude assets + * @property {CompressionAlgorithm} compressionAlgorithm compression algorithm + */ + +/** + * @param {StatsCompilation} bundleStats bundle stats + * @param {GenerateJSONReportOptions} opts options + * @returns {Promise} + */ async function generateJSONReport(bundleStats, opts) { const { reportFilename, diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..fb418069 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "moduleResolution": "node", + "allowJs": true, + "checkJs": true, + "strict": true, + "types": ["node"], + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true + }, + "include": ["./src/**/*"] +} From adc8d2464cc84f1a7b23a94014f95643041f0849 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 22 Feb 2026 20:14:19 +0300 Subject: [PATCH 2/3] lint: fix --- client/components/ContextMenu.jsx | 2 +- client/components/ContextMenuItem.jsx | 14 ++++++++++++++ client/components/ModulesTreemap.jsx | 5 +++++ client/components/Treemap.jsx | 10 ++++++++-- client/lib/PureComponent.jsx | 5 +++++ client/utils.js | 15 +++++++++++++++ test/helpers.js | 17 +++++++++++++++++ 7 files changed, 65 insertions(+), 3 deletions(-) diff --git a/client/components/ContextMenu.jsx b/client/components/ContextMenu.jsx index 1db649fa..d6caf4e4 100644 --- a/client/components/ContextMenu.jsx +++ b/client/components/ContextMenu.jsx @@ -107,7 +107,7 @@ export default class ContextMenu extends PureComponent { /** * Handle document-wide `mousedown` events to detect clicks * outside the context menu. - * @param {MouseEvent} event - DOM mouse event object + * @param {MouseEvent} event DOM mouse event object * @returns {void} */ handleDocumentMousedown = (event) => { diff --git a/client/components/ContextMenuItem.jsx b/client/components/ContextMenuItem.jsx index 3508e9d3..39a13e2e 100644 --- a/client/components/ContextMenuItem.jsx +++ b/client/components/ContextMenuItem.jsx @@ -3,10 +3,24 @@ import PropTypes from "prop-types"; import * as styles from "./ContextMenuItem.css"; +/** + * @returns {boolean} nothing + */ function noop() { return false; } +/** + * @typedef {object} ContextMenuItemProps + * @property {React.ReactNode} children children + * @property {boolean=} disabled - true when disabled, otherwise false + * @property {React.MouseEventHandler=} onClick on click handler + */ + +/** + * @param {ContextMenuItemProps} props props + * @returns {JSX.Element} context menu item + */ export default function ContextMenuItem({ children, disabled, onClick }) { const className = cls({ [styles.item]: true, diff --git a/client/components/ModulesTreemap.jsx b/client/components/ModulesTreemap.jsx index 7a2a9833..474719f2 100644 --- a/client/components/ModulesTreemap.jsx +++ b/client/components/ModulesTreemap.jsx @@ -18,6 +18,11 @@ import Switcher from "./Switcher.jsx"; import Tooltip from "./Tooltip.jsx"; import Treemap from "./Treemap.jsx"; +/** @typedef {"statSize" | "parsedSize" | "gzipSize" | "brotliSize" | "zstdSize"} PropSize */ + +/** + * @returns {{ label: string, prop: PropSize }[]} sizes + */ function getSizeSwitchItems() { const items = [ { label: "Stat", prop: "statSize" }, diff --git a/client/components/Treemap.jsx b/client/components/Treemap.jsx index d70e2185..9fcb5829 100644 --- a/client/components/Treemap.jsx +++ b/client/components/Treemap.jsx @@ -3,10 +3,17 @@ import { Component } from "preact"; import PropTypes from "prop-types"; import { SizeType, ViewerDataType } from "./types.js"; +/** + * @param {Event} event event + */ function preventDefault(event) { event.preventDefault(); } +/** + * @param {string} str string + * @returns {number} hash + */ function hashCode(str) { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -133,8 +140,7 @@ export default class Treemap extends Component { }, /** * Handle Foamtree's "group clicked" event - * @param {FoamtreeEvent} event - Foamtree event object - * (see https://get.carrotsearch.com/foamtree/demo/api/index.html#event-details) + * @param {FoamtreeEvent} event foamtree event object (see https://get.carrotsearch.com/foamtree/demo/api/index.html#event-details) * @returns {void} */ onGroupClick(event) { diff --git a/client/lib/PureComponent.jsx b/client/lib/PureComponent.jsx index 0c4614de..cf255aca 100644 --- a/client/lib/PureComponent.jsx +++ b/client/lib/PureComponent.jsx @@ -1,5 +1,10 @@ import { Component } from "preact"; +/** + * @param {object} obj1 obj1 + * @param {object} obj2 obj2 + * @returns {boolean} true when the same, otherwise false + */ function isEqual(obj1, obj2) { if (obj1 === obj2) return true; const keys = Object.keys(obj1); diff --git a/client/utils.js b/client/utils.js index 6e99cd40..2391822f 100644 --- a/client/utils.js +++ b/client/utils.js @@ -1,7 +1,16 @@ +/** + * @param {Chunk} chunk chunk + * @returns {boolean} true when chunk is parser, otherwise false + */ export function isChunkParsed(chunk) { return typeof chunk.parsedSize === "number"; } +/** + * @param {Module[]} modules modules + * @param {(module: Module) => boolean} cb callback + * @returns {boolean} state + */ export function walkModules(modules, cb) { for (const module of modules) { if (cb(module) === false) return false; @@ -12,6 +21,12 @@ export function walkModules(modules, cb) { } } +/** + * @template T + * @param {T} elem element + * @param {T[]} container container + * @returns {boolean} true when element is outside, otherwise false + */ export function elementIsOutside(elem, container) { return !(elem === container || container.contains(elem)); } diff --git a/test/helpers.js b/test/helpers.js index 9bf5cf05..59f7c2a4 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -5,6 +5,10 @@ const BundleAnalyzerPlugin = require("../src/BundleAnalyzerPlugin"); /* global it */ +/** + * @param {number} ms ms + * @returns {Promise} wait + */ function wait(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -16,6 +20,11 @@ const webpackVersions = { 5: path.resolve(__dirname, "../node_modules/webpack"), }; +/** + * @param {import("webpack").Configuration} config configuration + * @param {string} version version + * @returns {Promise} + */ async function webpackCompile(config, version) { if (version === undefined || version === null) { throw new Error("Webpack version is not specified"); @@ -56,6 +65,10 @@ async function webpackCompile(config, version) { await wait(1); } +/** + * @param {{ minify: boolean, multipleChunks: boolean, analyzerOpts: import("../src/BundleAnalyzerPlugin").Options }} opts options + * @returns {import("webpack").Configuration} configuration + */ function makeWebpackConfig(opts = {}) { opts = { ...opts, @@ -106,6 +119,10 @@ function makeWebpackConfig(opts = {}) { }; } +/** + * @param {("4", "5")[] | (() => "4" | "5")} versions versions + * @param {() => void} cb callback + */ function forEachWebpackVersion(versions, cb) { const availableVersions = Object.keys(webpackVersions); From d86b221cf13a28a408ef57de964151a618c6515c Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 22 Feb 2026 21:17:32 +0300 Subject: [PATCH 3/3] test: fix --- src/tree/Folder.js | 12 ++++-------- src/tree/Module.js | 25 ++++++++++++++----------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/tree/Folder.js b/src/tree/Folder.js index bcc69b59..a3047c3a 100644 --- a/src/tree/Folder.js +++ b/src/tree/Folder.js @@ -7,6 +7,7 @@ import { getModulePathParts } from "./utils.js"; /** @typedef {import("webpack").StatsModule} StatsModule */ /** @typedef {import("../analyzer").AnalyzerOptions} AnalyzerOptions */ /** @typedef {import("../analyzer").CompressionAlgorithm} CompressionAlgorithm */ +/** @typedef {import("./Module").SizeFields} SizeFields */ /** @typedef {import("./BaseFolder").BaseFolderChartData} BaseFolderChartData */ /** @@ -28,12 +29,6 @@ export default class Folder extends BaseFolder { super(name); /** @type {AnalyzerOptions} */ this.opts = opts; - /** @type {number | undefined} */ - this._gzipSize = undefined; - /** @type {number | undefined} */ - this._brotliSize = undefined; - /** @type {number | undefined} */ - this._zstdSize = undefined; } get parsedSize() { @@ -68,12 +63,13 @@ export default class Folder extends BaseFolder { (`_${compressionAlgorithm}Size`); if (!Object.hasOwn(this, key)) { - this[key] = this.src + /** @type {Folder & SizeFields} */ + (this)[key] = this.src ? getCompressedSize(compressionAlgorithm, this.src) : 0; } - return this[key]; + return /** @type {Folder & SizeFields} */ (this)[key]; } /** diff --git a/src/tree/Module.js b/src/tree/Module.js index 7abae7c2..7d223fbd 100644 --- a/src/tree/Module.js +++ b/src/tree/Module.js @@ -20,6 +20,13 @@ import Node from "./Node.js"; * @property {number | undefined} zstdSize zstd size */ +/** + * @typedef {object} SizeFields + * @property {number=} _gzipSize gzip size + * @property {number=} _brotliSize brotli size + * @property {number=} _zstdSize zstd size + */ + export default class Module extends Node { /** * @param {string} name name @@ -33,12 +40,6 @@ export default class Module extends Node { this.data = data; /** @type {ModuleOptions} */ this.opts = opts; - /** @type {number | undefined} */ - this._gzipSize = undefined; - /** @type {number | undefined} */ - this._brotliSize = undefined; - /** @type {number | undefined} */ - this._zstdSize = undefined; } get src() { @@ -47,9 +48,10 @@ export default class Module extends Node { set src(value) { this.data.parsedSrc = value; - delete this._gzipSize; - delete this._brotliSize; - delete this._zstdSize; + + delete (/** @type {Module & SizeFields} */ (this)._gzipSize); + delete (/** @type {Module & SizeFields} */ (this)._brotliSize); + delete (/** @type {Module & SizeFields} */ (this)._zstdSize); } /** @@ -110,12 +112,13 @@ export default class Module extends Node { /** @type {`_${CompressionAlgorithm}Size`} */ (`_${compressionAlgorithm}Size`); if (!(key in this)) { - this[key] = this.src + /** @type {Module & SizeFields} */ + (this)[key] = this.src ? getCompressedSize(compressionAlgorithm, this.src) : undefined; } - return this[key]; + return /** @type {Module & SizeFields} */ (this)[key]; } /**