inspectpack
Version:
An inspection tool for Webpack frontend JavaScript bundles.
290 lines (289 loc) • 12 kB
JavaScript
;
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;