UNPKG

18.9 kBJavaScriptView Raw
1#!/usr/bin/env node
2
3const fs = require('fs'),
4 fse = require('fs-extra'),
5 os = require('os'),
6 glob = require('glob'),
7 path = require('path'),
8 SourceMapConsumer = require('source-map').SourceMapConsumer,
9 convert = require('convert-source-map'),
10 temp = require('temp'),
11 ejs = require('ejs'),
12 open = require('opn'),
13 docopt = require('docopt').docopt,
14 btoa = require('btoa'),
15 packageJson = require('./package.json');
16
17const doc = [
18 'Analyze and debug space usage through source maps.',
19 '',
20 'Usage:',
21 ' source-map-explorer <script.js> [<script.js.map>]',
22 ' source-map-explorer [--json | --html | --tsv] [-m | --only-mapped] <script.js> [<script.js.map>] [--replace=BEFORE --with=AFTER]... [--noroot]',
23 ' source-map-explorer -h | --help | --version',
24 '',
25 'If the script file has an inline source map, you may omit the map parameter.',
26 '',
27 'Options:',
28 ' -h --help Show this screen.',
29 ' --version Show version.',
30 '',
31 ' --json Output JSON (on stdout) instead of generating HTML',
32 ' and opening the browser.',
33 ' --tsv Output TSV (on stdout) instead of generating HTML',
34 ' and opening the browser.',
35 ' --html Output HTML (on stdout) rather than opening a browser.',
36 '',
37 ' -m --only-mapped Exclude "unmapped" bytes from the output.',
38 ' This will result in total counts less than the file size',
39 '',
40 '',
41 ' --noroot To simplify the visualization, source-map-explorer',
42 ' will remove any prefix shared by all sources. If you',
43 ' wish to disable this behavior, set --noroot.',
44 '',
45 ' --replace=BEFORE Apply a simple find/replace on source file',
46 ' names. This can be used to fix some oddities',
47 ' with paths which appear in the source map',
48 ' generation process. Accepts regular expressions.',
49 ' --with=AFTER See --replace.',
50].join('\n');
51
52/**
53 * @typedef {Object} Args
54 * @property {string} `<script.js>` - Path to code file or Glob matching bundle files
55 * @property {(string|null)} `<script.js.map>` - Path to map file
56 * @property {boolean} `--json`
57 * @property {boolean} `--html`
58 * @property {boolean} `--tsv`
59 * @property {boolean} `--only-mapped`
60 * @property {boolean} `-m`
61 * @property {string[]} `--replace`
62 * @property {string[]} `--with`
63 * @property {boolean} `--noroot`
64 */
65
66/**
67 * @typedef {Object.<string, number>} FileSizeMap
68 */
69
70const helpers = {
71 /**
72 * @param {(Buffer|string)} file Path to file or Buffer
73 */
74 getFileContent(file) {
75 const buffer = Buffer.isBuffer(file) ? file : fs.readFileSync(file);
76
77 return buffer.toString();
78 },
79
80 /**
81 * Apply a transform to the keys of an object, leaving the values unaffected.
82 * @param {Object} obj
83 * @param {Function} fn
84 */
85 mapKeys(obj, fn) {
86 return Object.keys(obj).reduce((result, key) => {
87 const newKey = fn(key);
88 result[newKey] = obj[key];
89
90 return result;
91 }, {});
92 },
93
94 // https://stackoverflow.com/a/18650828/388951
95 formatBytes(bytes, decimals = 2) {
96 if (bytes == 0) return '0 B';
97
98 const k = 1000,
99 dm = decimals,
100 sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
101 i = Math.floor(Math.log(bytes) / Math.log(k));
102
103 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
104 },
105};
106
107function computeSpans(mapConsumer, generatedJs) {
108 var lines = generatedJs.split('\n');
109 var spans = [];
110 var numChars = 0;
111 var lastSource = false; // not a string, not null.
112 for (var line = 1; line <= lines.length; line++) {
113 var lineText = lines[line - 1];
114 var numCols = lineText.length;
115 for (var column = 0; column < numCols; column++, numChars++) {
116 var pos = mapConsumer.originalPositionFor({ line, column });
117 var source = pos.source;
118
119 if (source !== lastSource) {
120 lastSource = source;
121 spans.push({ source, numChars: 1 });
122 } else {
123 spans[spans.length - 1].numChars += 1;
124 }
125 }
126 }
127
128 return spans;
129}
130
131const UNMAPPED = '<unmapped>';
132
133/**
134 * Calculate the number of bytes contributed by each source file.
135 * @returns {
136 * files: {[sourceFile: string]: number},
137 * unmappedBytes: number,
138 * totalBytes: number
139 * }
140 */
141function computeGeneratedFileSizes(mapConsumer, generatedJs) {
142 var spans = computeSpans(mapConsumer, generatedJs);
143
144 var unmappedBytes = 0;
145 var files = {};
146 var totalBytes = 0;
147 for (var i = 0; i < spans.length; i++) {
148 var span = spans[i];
149 var numChars = span.numChars;
150 totalBytes += numChars;
151 if (span.source === null) {
152 unmappedBytes += numChars;
153 } else {
154 files[span.source] = (files[span.source] || 0) + span.numChars;
155 }
156 }
157
158 return {
159 files,
160 unmappedBytes,
161 totalBytes,
162 };
163}
164
165const SOURCE_MAP_INFO_URL =
166 'https://github.com/danvk/source-map-explorer/blob/master/README.md#generating-source-maps';
167
168/**
169 * Get source map
170 * @param {(string|Buffer)} jsFile
171 * @param {(string|Buffer)} mapFile
172 */
173function loadSourceMap(jsFile, mapFile) {
174 const jsData = helpers.getFileContent(jsFile);
175
176 var mapConsumer;
177 if (mapFile) {
178 const sourcemapData = helpers.getFileContent(mapFile);
179 mapConsumer = new SourceMapConsumer(sourcemapData);
180 } else {
181 // Try to read a source map from a 'sourceMappingURL' comment.
182 let converter = convert.fromSource(jsData);
183 if (!converter && !Buffer.isBuffer(jsFile)) {
184 converter = convert.fromMapFileSource(jsData, path.dirname(jsFile));
185 }
186 if (!converter) {
187 throw new Error(`Unable to find a source map.${os.EOL}See ${SOURCE_MAP_INFO_URL}`);
188 }
189 mapConsumer = new SourceMapConsumer(converter.toJSON());
190 }
191
192 if (!mapConsumer) {
193 throw new Error(`Unable to find a source map.${os.EOL}See ${SOURCE_MAP_INFO_URL}`);
194 }
195
196 return {
197 mapConsumer,
198 jsData,
199 };
200}
201
202/**
203 * Find common path prefix
204 * @see http://stackoverflow.com/a/1917041/388951
205 * @param {string[]} array List of filenames
206 */
207function commonPathPrefix(array) {
208 if (array.length === 0) return '';
209
210 const A = array.concat().sort(),
211 a1 = A[0].split(/(\/)/),
212 a2 = A[A.length - 1].split(/(\/)/),
213 L = a1.length;
214
215 let i = 0;
216
217 while (i < L && a1[i] === a2[i]) i++;
218
219 return a1.slice(0, i).join('');
220}
221
222function adjustSourcePaths(sizes, findRoot, replace) {
223 if (findRoot) {
224 var prefix = commonPathPrefix(Object.keys(sizes));
225 var len = prefix.length;
226 if (len) {
227 sizes = helpers.mapKeys(sizes, function(source) {
228 return source.slice(len);
229 });
230 }
231 }
232
233 if (!replace) {
234 replace = {};
235 }
236
237 var finds = Object.keys(replace);
238
239 for (var i = 0; i < finds.length; i++) {
240 var before = new RegExp(finds[i]),
241 after = replace[finds[i]];
242 sizes = helpers.mapKeys(sizes, function(source) {
243 return source.replace(before, after);
244 });
245 }
246
247 return sizes;
248}
249
250/**
251 * Validates CLI arguments
252 * @param {Args} args
253 */
254function validateArgs(args) {
255 if (args['--replace'].length !== args['--with'].length) {
256 console.error('--replace flags must be paired with --with flags.');
257 process.exit(1);
258 }
259}
260
261/**
262 * Covert file size map to webtreemap data
263 * @param {FileSizeMap} files
264 */
265function getWebTreeMapData(files) {
266 function newNode(name) {
267 return {
268 name: name,
269 data: {
270 $area: 0,
271 },
272 children: [],
273 };
274 }
275
276 function addNode(path, size) {
277 const parts = path.split('/');
278 let node = treeData;
279
280 node.data['$area'] += size;
281
282 parts.forEach(part => {
283 let child = node.children.find(child => child.name === part);
284
285 if (!child) {
286 child = newNode(part);
287 node.children.push(child);
288 }
289
290 node = child;
291 node.data['$area'] += size;
292 });
293 }
294
295 function addSizeToTitle(node, total) {
296 const size = node.data['$area'],
297 pct = (100.0 * size) / total;
298
299 node.name += ` • ${helpers.formatBytes(size)}${pct.toFixed(1)}%`;
300 node.children.forEach(child => {
301 addSizeToTitle(child, total);
302 });
303 }
304
305 const treeData = newNode('/');
306
307 for (const source in files) {
308 addNode(source, files[source]);
309 }
310 addSizeToTitle(treeData, treeData.data['$area']);
311
312 return treeData;
313}
314
315/**
316 * @typedef {Object} ExploreBatchResult
317 * @property {string} bundleName
318 * @property {number} totalBytes
319 * @property {FileSizeMap} files
320 */
321
322/**
323 * Create a combined result where each of the inputs is a separate node under the root.
324 * @param {ExploreBatchResult[]} exploreResults
325 * @returns ExploreBatchResult
326 */
327function makeMergedBundle(exploreResults) {
328 let totalBytes = 0;
329 const files = {};
330
331 // Remove any common prefix to keep the visualization as simple as possible.
332 const commonPrefix = commonPathPrefix(exploreResults.map(r => r.bundleName));
333
334 for (const result of exploreResults) {
335 totalBytes += result.totalBytes;
336 const prefix = result.bundleName.slice(commonPrefix.length);
337 Object.keys(result.files).forEach(fileName => {
338 const size = result.files[fileName];
339 files[prefix + '/' + fileName] = size;
340 });
341 }
342
343 return {
344 bundleName: '[combined]',
345 totalBytes,
346 files,
347 };
348}
349
350/**
351 * Generate HTML file content for specified files
352 * @param {ExploreBatchResult[]} exploreResults
353 */
354function generateHtml(exploreResults) {
355 const assets = {
356 webtreemapJs: btoa(fs.readFileSync(require.resolve('./vendor/webtreemap.js'))),
357 webtreemapCss: btoa(fs.readFileSync(require.resolve('./vendor/webtreemap.css'))),
358 };
359
360 // Create a combined bundle if applicable
361 if (exploreResults.length > 1) {
362 exploreResults = [makeMergedBundle(exploreResults)].concat(exploreResults);
363 }
364
365 // Get bundles info to generate select
366 const bundles = exploreResults.map(data => ({
367 name: data.bundleName,
368 size: helpers.formatBytes(data.totalBytes),
369 }));
370
371 // Get webtreemap data to update map on bundle select
372 const treeDataMap = exploreResults.reduce((result, data) => {
373 result[data.bundleName] = getWebTreeMapData(data.files);
374
375 return result;
376 }, {});
377
378 const template = fs.readFileSync(path.join(__dirname, 'tree-viz.ejs')).toString();
379
380 return ejs.render(template, {
381 bundles,
382 treeDataMap,
383 webtreemapJs: assets.webtreemapJs,
384 webtreemapCss: assets.webtreemapCss,
385 });
386}
387
388/**
389 * @typedef {Object} ExploreResult
390 * @property {number} totalBytes
391 * @property {number} unmappedBytes
392 * @property {FileSizeMap} files
393 * @property {string} [html]
394 */
395
396/**
397 * Analyze bundle
398 * @param {(string|Buffer)} code
399 * @param {(string|Buffer)} [map]
400 * @param {ExploreOptions} [options]
401 * @returns {ExploreResult[]}
402 */
403function explore(code, map, options) {
404 if (typeof options === 'undefined') {
405 if (typeof map === 'object' && !Buffer.isBuffer(map)) {
406 options = map;
407 map = undefined;
408 }
409 }
410
411 if (!options) {
412 options = {};
413 }
414
415 const data = loadSourceMap(code, map);
416 if (!data) {
417 throw new Error('Failed to load script and sourcemap');
418 }
419
420 const { mapConsumer, jsData } = data;
421
422 const sizes = computeGeneratedFileSizes(mapConsumer, jsData);
423 let files = sizes.files;
424
425 const filenames = Object.keys(files);
426 if (filenames.length === 1) {
427 const errorMessage = [
428 `Your source map only contains one source (${filenames[0]})`,
429 "This can happen if you use browserify+uglifyjs, for example, and don't set the --in-source-map flag to uglify.",
430 `See ${SOURCE_MAP_INFO_URL}`,
431 ].join(os.EOL);
432
433 throw new Error(errorMessage);
434 }
435
436 files = adjustSourcePaths(files, !options.noRoot, options.replace);
437
438 const { totalBytes, unmappedBytes } = sizes;
439
440 if (!options.onlyMapped) {
441 files[UNMAPPED] = unmappedBytes;
442 }
443
444 const result = {
445 totalBytes,
446 unmappedBytes,
447 files,
448 };
449
450 if (options.html) {
451 const title = Buffer.isBuffer(code) ? 'Buffer' : code;
452 result.html = generateHtml([
453 {
454 files,
455 totalBytes,
456 bundleName: title,
457 },
458 ]);
459 }
460
461 return result;
462}
463
464/**
465 * Wrap `explore` with Promise
466 * @param {Bundle} bundle
467 * @returns {Promise<ExploreBatchResult>}
468 */
469function explorePromisified({ codePath, mapPath }) {
470 return new Promise(resolve => {
471 const result = explore(codePath, mapPath);
472
473 resolve({
474 ...result,
475 bundleName: codePath,
476 });
477 });
478}
479
480/**
481 * @typedef {Object} Bundle
482 * @property {string} codePath Path to code file
483 * @property {string} mapPath Path to map file
484 */
485
486/**
487 * Expand codePath and mapPath into a list of { codePath, mapPath } pairs
488 * @see https://github.com/danvk/source-map-explorer/issues/52
489 * @param {string} codePath Path to bundle file or glob matching bundle files
490 * @param {string} [mapPath] Path to bundle map file
491 * @returns {Bundle[]}
492 */
493function getBundles(codePath, mapPath) {
494 if (codePath && mapPath) {
495 return [
496 {
497 codePath,
498 mapPath,
499 },
500 ];
501 }
502
503 const filenames = glob.sync(codePath);
504
505 const mapFilenames = glob.sync(codePath + '.map');
506
507 return filenames
508 .filter(filename => !filename.endsWith('.map'))
509 .map(filename => ({
510 codePath: filename,
511 mapPath: mapFilenames.find(mapFilename => mapFilename === `${filename}.map`),
512 }));
513}
514
515/**
516 * @typedef {Object} ExploreOptions
517 * @property {boolean} onlyMapped
518 * @property {boolean} html
519 * @property {boolean} noRoot
520 * @property {Object.<string, string>} replace
521 */
522
523/**
524 * Create options object for `explore` method
525 * @param {Args} args CLI arguments
526 * @returns {ExploreOptions}
527 */
528function getExploreOptions(args) {
529 let html = true;
530 if (args['--json'] || args['--tsv']) {
531 html = false;
532 }
533
534 const replace = {};
535 const argsReplace = args['--replace'];
536 const argsWith = args['--with'];
537 if (argsReplace && argsWith) {
538 for (let replaceIndex = 0; replaceIndex < argsReplace.length; replaceIndex += 1) {
539 replace[argsReplace[replaceIndex]] = argsWith[replaceIndex];
540 }
541 }
542
543 return {
544 onlyMapped: args['--only-mapped'] || args['-m'],
545 html,
546 noRoot: args['--noroot'],
547 replace,
548 };
549}
550
551/**
552 * Handle error during multiple bundles processing
553 * @param {Bundle} bundleInfo
554 * @param {Error} err
555 */
556function onExploreError(bundleInfo, err) {
557 if (err.code === 'ENOENT') {
558 console.error(`[${bundleInfo.codePath}] File not found! -- ${err.message}`);
559 } else {
560 console.error(`[${bundleInfo.codePath}]`, err.message);
561 }
562}
563
564function reportUnmappedBytes(data) {
565 const unmappedBytes = data.files[UNMAPPED];
566 if (unmappedBytes) {
567 const totalBytes = data.totalBytes;
568 const pct = (100 * unmappedBytes) / totalBytes;
569
570 const bytesString = pct.toFixed(2);
571
572 console.warn(
573 `[${data.bundleName}] Unable to map ${unmappedBytes}/${totalBytes} bytes (${bytesString}%)`
574 );
575 }
576}
577
578/**
579 * Write HTML content to a temporary file and open the file in a browser
580 * @param {string} html
581 */
582function writeToHtml(html) {
583 const tempName = temp.path({ suffix: '.html' });
584
585 fs.writeFileSync(tempName, html);
586
587 open(tempName, { wait: false }).catch(error => {
588 console.error('Unable to open web browser. ' + error);
589 console.error(
590 'Either run with --html, --json or --tsv, or view HTML for the visualization at:'
591 );
592 console.error(tempName);
593 });
594}
595
596/**
597 * Explore multiple bundles and write html output to file.
598 *
599 * @param {Bundle[]} bundles Bundles to explore
600 * @returns {Promise<Promise<ExploreBatchResult[]>}
601 */
602function exploreBundlesAndFilterErroneous(bundles) {
603 return Promise.all(
604 bundles.map(bundle => explorePromisified(bundle).catch(err => onExploreError(bundle, err)))
605 ).then(results => results.filter(data => data));
606}
607
608/**
609 * @typedef {Object} WriteConfig
610 * @property {string} [path] Path to write
611 * @property {string} fileName File name to write
612 */
613
614/**
615 * Explore multiple bundles and write html output to file.
616 *
617 * @param {WriteConfig} writeConfig
618 * @param {string} codePath Path to bundle file or glob matching bundle files
619 * @param {string} [mapPath] Path to bundle map file
620 * @returns {Bundle[]}
621 */
622function exploreBundlesAndWriteHtml(writeConfig, codePath, mapPath) {
623 const bundles = getBundles(codePath, mapPath);
624
625 return exploreBundlesAndFilterErroneous(bundles).then(results => {
626 if (results.length === 0) {
627 throw new Error('There were errors');
628 }
629
630 results.forEach(reportUnmappedBytes);
631
632 const html = generateHtml(results);
633
634 if (writeConfig.path !== undefined) {
635 // Use fse to support older node versions
636 fse.ensureDirSync(writeConfig.path);
637 }
638
639 const relPath =
640 writeConfig.path !== undefined
641 ? `${writeConfig.path}/${writeConfig.fileName}`
642 : writeConfig.fileName;
643
644 return fs.writeFileSync(relPath, html);
645 });
646}
647
648if (require.main === module) {
649 /** @type {Args} */
650 const args = docopt(doc, { version: packageJson.version });
651
652 validateArgs(args);
653
654 const bundles = getBundles(args['<script.js>'], args['<script.js.map>']);
655
656 if (bundles.length === 0) {
657 throw new Error('No file(s) found');
658 }
659
660 const exploreOptions = getExploreOptions(args);
661
662 if (bundles.length === 1) {
663 let data;
664
665 try {
666 const { codePath, mapPath } = bundles[0];
667 data = explore(codePath, mapPath, exploreOptions);
668 } catch (err) {
669 if (err.code === 'ENOENT') {
670 console.error(`File not found! -- ${err.message}`);
671 process.exit(1);
672 } else {
673 console.error(err.message);
674 process.exit(1);
675 }
676 }
677
678 reportUnmappedBytes(data);
679
680 if (args['--json']) {
681 console.log(JSON.stringify(data.files, null, ' '));
682 process.exit(0);
683 } else if (args['--tsv']) {
684 console.log('Source\tSize');
685 Object.keys(data.files).forEach(source => {
686 const size = data.files[source];
687 console.log(`${size}\t${source}`);
688 });
689 process.exit(0);
690 } else if (args['--html']) {
691 console.log(data.html);
692 process.exit(0);
693 }
694
695 writeToHtml(data.html);
696 } else {
697 exploreBundlesAndFilterErroneous(bundles).then(results => {
698 if (results.length === 0) {
699 throw new Error('There were errors');
700 }
701
702 results.forEach(reportUnmappedBytes);
703
704 const html = generateHtml(results);
705
706 // Check args instead of exploreOptions.html because it always true
707 if (args['--html']) {
708 console.log(html);
709 } else {
710 writeToHtml(html);
711 }
712 });
713 }
714}
715
716module.exports = explore;
717module.exports.generateHtml = generateHtml;
718module.exports.exploreBundlesAndWriteHtml = exploreBundlesAndWriteHtml;
719
720// Exports are here mostly for testing.
721module.exports.loadSourceMap = loadSourceMap;
722module.exports.computeGeneratedFileSizes = computeGeneratedFileSizes;
723module.exports.adjustSourcePaths = adjustSourcePaths;
724module.exports.mapKeys = helpers.mapKeys;
725module.exports.commonPathPrefix = commonPathPrefix;
726module.exports.getBundles = getBundles;