1 | #!/usr/bin/env node
|
2 |
|
3 | const 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 |
|
17 | const 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 |
|
54 |
|
55 |
|
56 |
|
57 |
|
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 |
|
64 |
|
65 |
|
66 |
|
67 |
|
68 |
|
69 |
|
70 | const helpers = {
|
71 | |
72 |
|
73 |
|
74 | getFileContent(file) {
|
75 | const buffer = Buffer.isBuffer(file) ? file : fs.readFileSync(file);
|
76 |
|
77 | return buffer.toString();
|
78 | },
|
79 |
|
80 | |
81 |
|
82 |
|
83 |
|
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 |
|
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 |
|
107 | function computeSpans(mapConsumer, generatedJs) {
|
108 | var lines = generatedJs.split('\n');
|
109 | var spans = [];
|
110 | var numChars = 0;
|
111 | var lastSource = false;
|
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 |
|
131 | const UNMAPPED = '<unmapped>';
|
132 |
|
133 |
|
134 |
|
135 |
|
136 |
|
137 |
|
138 |
|
139 |
|
140 |
|
141 | function 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 |
|
165 | const SOURCE_MAP_INFO_URL =
|
166 | 'https://github.com/danvk/source-map-explorer/blob/master/README.md#generating-source-maps';
|
167 |
|
168 |
|
169 |
|
170 |
|
171 |
|
172 |
|
173 | function 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 |
|
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 |
|
204 |
|
205 |
|
206 |
|
207 | function 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 |
|
222 | function 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 |
|
252 |
|
253 |
|
254 | function 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 |
|
263 |
|
264 |
|
265 | function 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 |
|
317 |
|
318 |
|
319 |
|
320 |
|
321 |
|
322 |
|
323 |
|
324 |
|
325 |
|
326 |
|
327 | function makeMergedBundle(exploreResults) {
|
328 | let totalBytes = 0;
|
329 | const files = {};
|
330 |
|
331 |
|
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 |
|
352 |
|
353 |
|
354 | function 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 |
|
361 | if (exploreResults.length > 1) {
|
362 | exploreResults = [makeMergedBundle(exploreResults)].concat(exploreResults);
|
363 | }
|
364 |
|
365 |
|
366 | const bundles = exploreResults.map(data => ({
|
367 | name: data.bundleName,
|
368 | size: helpers.formatBytes(data.totalBytes),
|
369 | }));
|
370 |
|
371 |
|
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 |
|
390 |
|
391 |
|
392 |
|
393 |
|
394 |
|
395 |
|
396 |
|
397 |
|
398 |
|
399 |
|
400 |
|
401 |
|
402 |
|
403 | function 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 |
|
466 |
|
467 |
|
468 |
|
469 | function 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 |
|
482 |
|
483 |
|
484 |
|
485 |
|
486 |
|
487 |
|
488 |
|
489 |
|
490 |
|
491 |
|
492 |
|
493 | function 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 |
|
517 |
|
518 |
|
519 |
|
520 |
|
521 |
|
522 |
|
523 |
|
524 |
|
525 |
|
526 |
|
527 |
|
528 | function 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 |
|
553 |
|
554 |
|
555 |
|
556 | function 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 |
|
564 | function 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 |
|
580 |
|
581 |
|
582 | function 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 |
|
598 |
|
599 |
|
600 |
|
601 |
|
602 | function 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 |
|
610 |
|
611 |
|
612 |
|
613 |
|
614 |
|
615 |
|
616 |
|
617 |
|
618 |
|
619 |
|
620 |
|
621 |
|
622 | function 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 |
|
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 |
|
648 | if (require.main === module) {
|
649 |
|
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 |
|
707 | if (args['--html']) {
|
708 | console.log(html);
|
709 | } else {
|
710 | writeToHtml(html);
|
711 | }
|
712 | });
|
713 | }
|
714 | }
|
715 |
|
716 | module.exports = explore;
|
717 | module.exports.generateHtml = generateHtml;
|
718 | module.exports.exploreBundlesAndWriteHtml = exploreBundlesAndWriteHtml;
|
719 |
|
720 |
|
721 | module.exports.loadSourceMap = loadSourceMap;
|
722 | module.exports.computeGeneratedFileSizes = computeGeneratedFileSizes;
|
723 | module.exports.adjustSourcePaths = adjustSourcePaths;
|
724 | module.exports.mapKeys = helpers.mapKeys;
|
725 | module.exports.commonPathPrefix = commonPathPrefix;
|
726 | module.exports.getBundles = getBundles;
|