UNPKG

inspectpack

Version:

An inspection tool for Webpack frontend JavaScript bundles.

290 lines (289 loc) 12 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Template = exports.TemplateFormat = exports.Action = exports._getBaseName = exports._normalizeWebpackPath = exports._isNodeModules = exports.nodeModulesParts = void 0; const Either_1 = require("fp-ts/lib/Either"); const io_ts_reporters_1 = require("io-ts-reporters"); const path_1 = require("path"); const webpack_stats_1 = require("../interfaces/webpack-stats"); const files_1 = require("../util/files"); const strings_1 = require("../util/strings"); // Note: Should only use with strings from `toPosixName()`. const NM_RE = /(^|\/)(node_modules|\~)(\/|$)/g; const nodeModulesParts = (name) => files_1.toPosixPath(name).split(NM_RE); exports.nodeModulesParts = nodeModulesParts; // True if name is part of a `node_modules` path. const _isNodeModules = (name) => exports.nodeModulesParts(name).length > 1; exports._isNodeModules = _isNodeModules; // Attempt to "unwind" webpack paths in `identifier` and `name` to remove // prefixes and produce a normal, usable filepath. // // First, strip off anything before a `?` and `!`: // - `REMOVE?KEEP` // - `REMOVE!KEEP` // // TODO(106): Revise code and tests for `fullPath`. // https://github.com/FormidableLabs/inspectpack/issues/106 const _normalizeWebpackPath = (identifier, name) => { const bangLastIdx = identifier.lastIndexOf("!"); const questionLastIdx = identifier.lastIndexOf("?"); const prefixEnd = Math.max(bangLastIdx, questionLastIdx); let candidate = identifier; // Remove prefix here. if (prefixEnd > -1) { candidate = candidate.substr(prefixEnd + 1); } // Naive heuristic: remove known starting webpack tokens. candidate = candidate.replace(/^(multi |ignored )/, ""); // Assume a normalized then truncate to name if applicable. // // E.g., // - `identifier`: "css /PATH/TO/node_modules/cache-loader/dist/cjs.js!STUFF // !/PATH/TO/node_modules/font-awesome/css/font-awesome.css 0" // - `name`: "node_modules/font-awesome/css/font-awesome.css" // // Forms of name: // - v1, v2: "/PATH/TO/ROOT/~/pkg/index.js" // - v3: "/PATH/TO/ROOT/node_modules/pkg/index.js" // - v4: "./node_modules/pkg/index.js" if (name) { name = name .replace("/~/", "/node_modules/") .replace("\\~\\", "\\node_modules\\"); if (name.startsWith("./") || name.startsWith(".\\")) { // Remove dot-slash relative part. name = name.slice(2); } // Now, truncate suffix of the candidate if name has less. const nameLastIdx = candidate.lastIndexOf(name); if (nameLastIdx > -1 && candidate.length !== nameLastIdx + name.length) { candidate = candidate.substr(0, nameLastIdx + name.length); } } return candidate; }; exports._normalizeWebpackPath = _normalizeWebpackPath; // Convert a `node_modules` name to a base name. // // **Note**: Assumes only passed `node_modules` values. // // Normalizations: // - Remove starting path if `./` // - Switch Windows paths to Mac/Unix style. const _getBaseName = (name) => { // Slice to just after last occurrence of node_modules. const parts = exports.nodeModulesParts(name); const lastName = parts[parts.length - 1]; // Normalize out the rest of the string. let candidate = path_1.normalize(path_1.relative(".", lastName)); // Short-circuit on empty string / current path. if (candidate === ".") { return ""; } // Special case -- synthetic modules can end up with trailing `/` because // of a regular expression. Preserve this. // // E.g., `/PATH/TO/node_modules/moment/locale sync /es/` // // **Note**: The rest of this tranform _should_ be safe for synthetic regexps, // but we can always revisit. if (name[name.length - 1] === "/") { candidate += "/"; } return files_1.toPosixPath(candidate); }; exports._getBaseName = _getBaseName; class Action { constructor({ stats, ignoredPackages }) { this.stats = stats; this._ignoredPackages = (ignoredPackages || []) .map((pattern) => typeof pattern === "string" ? `${pattern}/` : pattern); } validate() { return Promise.resolve() .then(() => { // Validate the stats object. const result = webpack_stats_1.RWebpackStats.decode(this.stats); if (Either_1.isLeft(result)) { const errs = io_ts_reporters_1.reporter(result); throw new Error(`Invalid webpack stats object. (Errors: ${errs.join(", ")})`); } }) .then(() => this); } // Create the internal data object for this action. // // This is a memoizing wrapper on the abstract internal method actions // must implement. getData() { return Promise.resolve() .then(() => this._data || this._getData()) .then((data) => this._data = data); } // Flat array of webpack source modules only. (Memoized) get modules() { return this._modules = this._modules || this.getSourceMods(this.stats.modules); } // Whether or not we consider the data to indicate we should bail with error. shouldBail() { return Promise.resolve(false); } ignorePackage(baseName) { const base = files_1.toPosixPath(baseName.trim()); return this._ignoredPackages.some((pattern) => typeof pattern === "string" ? base.startsWith(pattern) : pattern.test(base)); } getSourceMods(mods, parentChunks) { return mods // Recursively flatten to list of source modules. .reduce((list, mod) => { // Add in any parent chunks and ensure unique array. const chunks = Array.from(new Set(mod.chunks.concat(parentChunks || []))); // Fields let isSynthetic = false; let source = null; let identifier; let name; let size; if (Either_1.isRight(webpack_stats_1.RWebpackStatsModuleModules.decode(mod))) { // Recursive case -- more modules. const modsMod = mod; // Return and recurse. return list.concat(this.getSourceMods(modsMod.modules, chunks)); } else if (Either_1.isRight(webpack_stats_1.RWebpackStatsModuleSource.decode(mod))) { // webpack5+: Check if an orphan and just skip entirely. if (Either_1.isRight(webpack_stats_1.RWebpackStatsModuleOrphan.decode(mod)) && mod.orphan) { return list; } // Base case -- a normal source code module that is **not** an orphan. const srcMod = mod; identifier = srcMod.identifier; name = srcMod.name; // Note: there are isolated cases where webpack4 appears to be // wrong in it's `size` estimation vs the actual string length. // See `version mismatch for v1-v4 moment-app` wherein the // real length of `moment/locale/es-us.js` is 3017 but webpack // v4 reports it in stats object as 3029. size = srcMod.source.length || srcMod.size; source = srcMod.source; } else if (Either_1.isRight(webpack_stats_1.RWebpackStatsModuleSynthetic.decode(mod))) { // Catch-all case -- a module without modules or source. const syntheticMod = mod; identifier = syntheticMod.identifier; name = syntheticMod.name; size = syntheticMod.size; isSynthetic = true; } else { throw new Error(`Cannot match to known module type: ${JSON.stringify(mod)}`); } // We've now got a single entry to prepare and add. const normalizedName = exports._normalizeWebpackPath(name); const normalizedId = exports._normalizeWebpackPath(identifier, normalizedName); const isNodeModules = exports._isNodeModules(normalizedId); const baseName = isNodeModules ? exports._getBaseName(normalizedId) : null; if (baseName && this.ignorePackage(baseName)) { return list; } return list.concat([{ baseName, chunks, identifier, isNodeModules, isSynthetic, size, source, }]); }, []) // Sort: via https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare .sort((a, b) => a.identifier.localeCompare(b.identifier)); } // Object of source modules grouped by asset. (Memoized) get assets() { return this._assets = this._assets || this.getSourceAssets(this.stats.assets); } getSourceAssets(assets) { // Helper: LUT from chunk to asset name. const chunksToAssets = {}; // Actual working data object. const modulesSetByAsset = {}; // Limit assets to possible JS files. const jsAssets = assets.filter((asset) => /\.(m|)js$/.test(asset.name)); // Iterate assets and begin populating structures. jsAssets.forEach((asset) => { modulesSetByAsset[asset.name] = { asset, mods: new Set(), }; asset.chunks.forEach((chunk) => { // Skip null chunks, allowing only strings or numbers. if (chunk === null) { return; } chunk = chunk.toString(); // force to string. chunksToAssets[chunk] = chunksToAssets[chunk] || new Set(); // Add unique assets. chunksToAssets[chunk].add(asset.name); }); }); // Iterate modules and attach as appropriate. this.modules.forEach((mod) => { mod.chunks.forEach((chunk) => { // Skip null chunks, allowing only strings or numbers. if (chunk === null) { return; } chunk = chunk.toString(); // force to string. (chunksToAssets[chunk] || []).forEach((assetName) => { const assetObj = modulesSetByAsset[assetName]; if (assetObj) { assetObj.mods.add(mod); } }); }); }); // Convert to final form return Object.keys(modulesSetByAsset) .sort(strings_1.sort) .reduce((memo, assetName) => { const assetSetObj = modulesSetByAsset[assetName]; memo[assetName] = { asset: assetSetObj.asset, mods: Array.from(assetSetObj.mods), }; return memo; }, {}); } get template() { this._template = this._template || this._createTemplate(); return this._template; } } exports.Action = Action; var TemplateFormat; (function (TemplateFormat) { TemplateFormat["json"] = "json"; TemplateFormat["text"] = "text"; TemplateFormat["tsv"] = "tsv"; })(TemplateFormat = exports.TemplateFormat || (exports.TemplateFormat = {})); class Template { constructor({ action }) { this.action = action; } json() { return this.action.getData().then((data) => JSON.stringify(data, null, 2)); } render(format) { return this[format](); } trim(str, num) { return str .trimRight() // trailing space. .replace(/^[ ]*\s*/m, "") // First line, if empty. .replace(new RegExp(`^[ ]{${num}}`, "gm"), ""); } } exports.Template = Template;