UNPKG

source-map-explorer

Version:

Analyze and debug space usage through source maps

656 lines (554 loc) 17.3 kB
#!/usr/bin/env node const fs = require('fs'), os = require('os'), glob = require('glob'), path = require('path'), SourceMapConsumer = require('source-map').SourceMapConsumer, convert = require('convert-source-map'), temp = require('temp'), ejs = require('ejs'), open = require('opn'), docopt = require('docopt').docopt, btoa = require('btoa'), packageJson = require('./package.json'); const doc = [ 'Analyze and debug space usage through source maps.', '', 'Usage:', ' source-map-explorer <script.js> [<script.js.map>]', ' source-map-explorer [--json | --html | --tsv] [-m | --only-mapped] <script.js> [<script.js.map>] [--replace=BEFORE --with=AFTER]... [--noroot]', ' source-map-explorer -h | --help | --version', '', 'If the script file has an inline source map, you may omit the map parameter.', '', 'Options:', ' -h --help Show this screen.', ' --version Show version.', '', ' --json Output JSON (on stdout) instead of generating HTML', ' and opening the browser.', ' --tsv Output TSV (on stdout) instead of generating HTML', ' and opening the browser.', ' --html Output HTML (on stdout) rather than opening a browser.', '', ' -m --only-mapped Exclude "unmapped" bytes from the output.', ' This will result in total counts less than the file size', '', '', ' --noroot To simplify the visualization, source-map-explorer', ' will remove any prefix shared by all sources. If you', ' wish to disable this behavior, set --noroot.', '', ' --replace=BEFORE Apply a simple find/replace on source file', ' names. This can be used to fix some oddities', ' with paths which appear in the source map', ' generation process. Accepts regular expressions.', ' --with=AFTER See --replace.', ].join('\n'); /** * @typedef {Object} Args * @property {string} `<script.js>` - Path to code file or Glob matching bundle files * @property {(string|null)} `<script.js.map>` - Path to map file * @property {boolean} `--json` * @property {boolean} `--html` * @property {boolean} `--tsv` * @property {boolean} `--only-mapped` * @property {boolean} `-m` * @property {string[]} `--replace` * @property {string[]} `--with` * @property {boolean} `--noroot` */ /** * @typedef {Object.<string, number>} FileSizeMap */ const helpers = { /** * @param {(Buffer|string)} file Path to file or Buffer */ getFileContent(file) { const buffer = Buffer.isBuffer(file) ? file : fs.readFileSync(file); return buffer.toString(); }, /** * Apply a transform to the keys of an object, leaving the values unaffected. * @param {Object} obj * @param {Function} fn */ mapKeys(obj, fn) { return Object.keys(obj).reduce((result, key) => { const newKey = fn(key); result[newKey] = obj[key]; return result; }, {}); }, // https://stackoverflow.com/a/18650828/388951 formatBytes(bytes, decimals = 2) { if (bytes == 0) return '0 B'; const k = 1000, dm = decimals, sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } }; function computeSpans(mapConsumer, generatedJs) { var lines = generatedJs.split('\n'); var spans = []; var numChars = 0; var lastSource = false; // not a string, not null. for (var line = 1; line <= lines.length; line++) { var lineText = lines[line - 1]; var numCols = lineText.length; for (var column = 0; column < numCols; column++ , numChars++) { var pos = mapConsumer.originalPositionFor({ line, column }); var source = pos.source; if (source !== lastSource) { lastSource = source; spans.push({ source, numChars: 1 }); } else { spans[spans.length - 1].numChars += 1; } } } return spans; } const UNMAPPED = '<unmapped>'; /** * Calculate the number of bytes contributed by each source file. * @returns { * files: {[sourceFile: string]: number}, * unmappedBytes: number, * totalBytes: number * } */ function computeGeneratedFileSizes(mapConsumer, generatedJs) { var spans = computeSpans(mapConsumer, generatedJs); var unmappedBytes = 0; var files = {}; var totalBytes = 0; for (var i = 0; i < spans.length; i++) { var span = spans[i]; var numChars = span.numChars; totalBytes += numChars; if (span.source === null) { unmappedBytes += numChars; } else { files[span.source] = (files[span.source] || 0) + span.numChars; } } return { files, unmappedBytes, totalBytes }; } const SOURCE_MAP_INFO_URL = 'https://github.com/danvk/source-map-explorer/blob/master/README.md#generating-source-maps'; /** * Get source map * @param {(string|Buffer)} jsFile * @param {(string|Buffer)} mapFile */ function loadSourceMap(jsFile, mapFile) { const jsData = helpers.getFileContent(jsFile); var mapConsumer; if (mapFile) { const sourcemapData = helpers.getFileContent(mapFile); mapConsumer = new SourceMapConsumer(sourcemapData); } else { // Try to read a source map from a 'sourceMappingURL' comment. let converter = convert.fromSource(jsData); if (!converter && !Buffer.isBuffer(jsFile)) { converter = convert.fromMapFileSource(jsData, path.dirname(jsFile)); } if (!converter) { throw new Error(`Unable to find a source map.${os.EOL}See ${SOURCE_MAP_INFO_URL}`); } mapConsumer = new SourceMapConsumer(converter.toJSON()); } if (!mapConsumer) { throw new Error(`Unable to find a source map.${os.EOL}See ${SOURCE_MAP_INFO_URL}`); } return { mapConsumer, jsData }; } /** * Find common path prefix * @see http://stackoverflow.com/a/1917041/388951 * @param {string[]} array List of filenames */ function commonPathPrefix(array) { if (array.length === 0) return ''; const A = array.concat().sort(), a1 = A[0].split(/(\/)/), a2 = A[A.length - 1].split(/(\/)/), L = a1.length; let i = 0; while (i < L && a1[i] === a2[i]) i++; return a1.slice(0, i).join(''); } function adjustSourcePaths(sizes, findRoot, replace) { if (findRoot) { var prefix = commonPathPrefix(Object.keys(sizes)); var len = prefix.length; if (len) { sizes = helpers.mapKeys(sizes, function (source) { return source.slice(len); }); } } if (!replace) { replace = {}; } var finds = Object.keys(replace); for (var i = 0; i < finds.length; i++) { var before = new RegExp(finds[i]), after = replace[finds[i]]; sizes = helpers.mapKeys(sizes, function (source) { return source.replace(before, after); }); } return sizes; } /** * Validates CLI arguments * @param {Args} args */ function validateArgs(args) { if (args['--replace'].length !== args['--with'].length) { console.error('--replace flags must be paired with --with flags.'); process.exit(1); } } /** * Covert file size map to webtreemap data * @param {FileSizeMap} files */ function getWebTreeMapData(files) { function newNode(name) { return { name: name, data: { '$area': 0 }, children: [] }; } function addNode(path, size) { const parts = path.split('/'); let node = treeData; node.data['$area'] += size; parts.forEach(part => { let child = node.children.find(child => child.name === part); if (!child) { child = newNode(part); node.children.push(child); } node = child; node.data['$area'] += size; }); } function addSizeToTitle(node, total) { const size = node.data['$area'], pct = 100.0 * size / total; node.name += ` • ${helpers.formatBytes(size)}${pct.toFixed(1)}%`; node.children.forEach(child => { addSizeToTitle(child, total); }); } const treeData = newNode('/'); for (let source in files) { addNode(source, files[source]); } addSizeToTitle(treeData, treeData.data['$area']); return treeData; } /** * @typedef {Object} ExploreBatchResult * @property {string} bundleName * @property {number} totalBytes * @property {FileSizeMap} files */ /** * Create a combined result where each of the inputs is a separate node under the root. * @param {ExploreBatchResult[]} exploreResults * @returns ExploreBatchResult */ function makeMergedBundle(exploreResults) { let totalBytes = 0; const files = {}; // Remove any common prefix to keep the visualization as simple as possible. const commonPrefix = commonPathPrefix(exploreResults.map(r => r.bundleName)); for (const result of exploreResults) { totalBytes += result.totalBytes; const prefix = result.bundleName.slice(commonPrefix.length); Object.keys(result.files).forEach(fileName => { const size = result.files[fileName]; files[prefix + '/' + fileName] = size; }); } return { bundleName: '[combined]', totalBytes, files }; } /** * Generate HTML file content for specified files * @param {ExploreBatchResult[]} exploreResults */ function generateHtml(exploreResults) { const assets = { webtreemapJs: btoa(fs.readFileSync(require.resolve('./vendor/webtreemap.js'))), webtreemapCss: btoa(fs.readFileSync(require.resolve('./vendor/webtreemap.css'))) }; // Create a combined bundle if applicable if (exploreResults.length > 1) { exploreResults = [makeMergedBundle(exploreResults)].concat(exploreResults); } // Get bundles info to generate select const bundles = exploreResults.map(data => ({ name: data.bundleName, size: helpers.formatBytes(data.totalBytes) })); // Get webtreemap data to update map on bundle select const treeDataMap = exploreResults.reduce((result, data) => { result[data.bundleName] = getWebTreeMapData(data.files); return result; }, {}); const template = fs.readFileSync(path.join(__dirname, 'tree-viz.ejs')).toString(); return ejs.render(template, { bundles, treeDataMap, webtreemapJs: assets.webtreemapJs, webtreemapCss: assets.webtreemapCss }); } /** * @typedef {Object} ExploreResult * @property {number} totalBytes * @property {number} unmappedBytes * @property {FileSizeMap} files * @property {string} [html] */ /** * Analyze bundle * @param {(string|Buffer)} code * @param {(string|Buffer)} [map] * @param {ExploreOptions} [options] * @returns {ExploreResult[]} */ function explore(code, map, options) { if (typeof options === 'undefined') { if (typeof map === 'object' && !Buffer.isBuffer(map)) { options = map; map = undefined; } } if (!options) { options = {}; } const data = loadSourceMap(code, map); if (!data) { throw new Error('Failed to load script and sourcemap'); } const { mapConsumer, jsData } = data; const sizes = computeGeneratedFileSizes(mapConsumer, jsData); let files = sizes.files; const filenames = Object.keys(files); if (filenames.length === 1) { const errorMessage = [ `Your source map only contains one source (${filenames[0]})`, 'This can happen if you use browserify+uglifyjs, for example, and don\'t set the --in-source-map flag to uglify.', `See ${SOURCE_MAP_INFO_URL}`].join(os.EOL); throw new Error(errorMessage); } files = adjustSourcePaths(files, !options.noRoot, options.replace); const { totalBytes, unmappedBytes } = sizes; if (!options.onlyMapped) { files[UNMAPPED] = unmappedBytes; } const result = { totalBytes, unmappedBytes, files }; if (options.html) { const title = Buffer.isBuffer(code) ? 'Buffer' : code; result.html = generateHtml([{ files, totalBytes, bundleName: title }]); } return result; } /** * Wrap `explore` with Promise * @param {Bundle} bundle * @returns {Promise<ExploreBatchResult>} */ function explorePromisified({ codePath, mapPath }) { return new Promise(resolve => { const result = explore(codePath, mapPath); resolve({ ...result, bundleName: codePath }); }); } /** * @typedef {Object} Bundle * @property {string} codePath Path to code file * @property {string} mapPath Path to map file */ /** * Expand codePath and mapPath into a list of { codePath, mapPath } pairs * @see https://github.com/danvk/source-map-explorer/issues/52 * @param {string} codePath Path to bundle file or glob matching bundle files * @param {string} [mapPath] Path to bundle map file * @returns {Bundle[]} */ function getBundles(codePath, mapPath) { if (codePath && mapPath) { return [{ codePath, mapPath }]; } const filenames = glob.sync(codePath); const mapFilenames = filenames.filter(filename => filename.endsWith('.map')); return filenames .filter(filename => !filename.endsWith('.map')) .map(filename => ({ codePath: filename, mapPath: mapFilenames.find(mapFilename => mapFilename === `${filename}.map`) })); } /** * @typedef {Object} ExploreOptions * @property {boolean} onlyMapped * @property {boolean} html * @property {boolean} noRoot * @property {Object.<string, string>} replace */ /** * Create options object for `explore` method * @param {Args} args CLI arguments * @returns {ExploreOptions} */ function getExploreOptions(args) { let html = true; if (args['--json'] || args['--tsv']) { html = false; } const replace = {}; const argsReplace = args['--replace']; const argsWith = args['--with']; if (argsReplace && argsWith) { for (let replaceIndex = 0; replaceIndex < argsReplace.length; replaceIndex += 1) { replace[argsReplace[replaceIndex]] = argsWith[replaceIndex]; } } return { onlyMapped: args['--only-mapped'] || args['-m'], html, noRoot: args['--noroot'], replace }; } /** * Handle error during multiple bundles processing * @param {Bundle} bundleInfo * @param {Error} err */ function onExploreError(bundleInfo, err) { if (err.code === 'ENOENT') { console.error(`[${bundleInfo.codePath}] File not found! -- ${err.message}`); } else { console.error(`[${bundleInfo.codePath}]`, err.message); } } function reportUnmappedBytes(data) { const unmappedBytes = data.files[UNMAPPED]; if (unmappedBytes) { const totalBytes = data.totalBytes; const pct = 100 * unmappedBytes / totalBytes; console.warn(`[${data.bundleName}] Unable to map ${unmappedBytes}/${totalBytes} bytes (${pct.toFixed(2)}%)`); } } /** * Write HTML content to a temporary file and open the file in a browser * @param {string} html */ function writeToHtml(html) { const tempName = temp.path({ suffix: '.html' }); fs.writeFileSync(tempName, html); open(tempName, {wait: false}).catch(error => { console.error('Unable to open web browser. ' + error); console.error('Either run with --html, --json or --tsv, or view HTML for the visualization at:'); console.error(tempName); }); } if (require.main === module) { /** @type {Args} */ const args = docopt(doc, { version: packageJson.version }); validateArgs(args); const bundles = getBundles(args['<script.js>'], args['<script.js.map>']); if (bundles.length === 0) { throw new Error('No file(s) found'); } const exploreOptions = getExploreOptions(args); if (bundles.length === 1) { let data; try { const { codePath, mapPath } = bundles[0]; data = explore(codePath, mapPath, exploreOptions); } catch (err) { if (err.code === 'ENOENT') { console.error(`File not found! -- ${err.message}`); process.exit(1); } else { console.error(err.message); process.exit(1); } } reportUnmappedBytes(data); if (args['--json']) { console.log(JSON.stringify(data.files, null, ' ')); process.exit(0); } else if (args['--tsv']) { console.log('Source\tSize'); Object.keys(data.files).forEach(source => { const size = data.files[source]; console.log(`${size}\t${source}`); }); process.exit(0); } else if (args['--html']) { console.log(data.html); process.exit(0); } writeToHtml(data.html); } else { // Do not generate HTML when exploring multiple bundles exploreOptions.html = false; Promise.all(bundles .map(bundle => explorePromisified(bundle, exploreOptions) .catch(err => onExploreError(bundle, err)))) .then(results => results.filter(data => data)) // Exclude erroneous results .then(results => { if (results.length === 0) { throw new Error('There were errors'); } results.forEach(reportUnmappedBytes); const html = generateHtml(results); writeToHtml(html); }); } } module.exports = explore; module.exports.generateHtml = generateHtml; // Exports are here mostly for testing. module.exports.loadSourceMap = loadSourceMap; module.exports.computeGeneratedFileSizes = computeGeneratedFileSizes; module.exports.adjustSourcePaths = adjustSourcePaths; module.exports.mapKeys = helpers.mapKeys; module.exports.commonPathPrefix = commonPathPrefix; module.exports.getBundles = getBundles;