├── .gitignore ├── LICENSE ├── README.md ├── bin └── startProxy.js ├── doc ├── file.png └── ouput.png ├── lib ├── httpProxy.js └── template.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Gaël Métais 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | There are already several tools that tell you the percent of unused **CSS** on a webpage. But what about unused **JS**? Well this tool does it for JS. 2 | 3 | It is a browser proxy written in NodeJS. 4 | 5 | 6 | ## How does it work ? 7 | 8 | 1. The proxy intercepts incoming javascript files. 9 | 2. Each script is instrumented on the fly by a test coverage tool ([Istanbul](https://github.com/gotwarlost/istanbul)). 10 | 3. When the script executes on the page, coverage metrics are collected in the background. 11 | 12 | 13 | #### Be careful, the proxy is not working with HTTPS files. 14 | 15 | JS files loaded over HTTPS are ignored. Proxies can't intercept SSL communication. 16 | 17 | 18 | ## Installation 19 | 20 | You need to open your console and write: 21 | 22 | ```bash 23 | npm install unusedjs -g 24 | ``` 25 | 26 | 27 | ## Use 28 | 29 | 1. Start the server by writing in your console: `unused-js-proxy` 30 | 31 | 2. Configure your browser's proxy to `localhost:3838`. Only set the HTTP proxy, let the HTTPS (=SSL) proxy empty. 32 | 33 | 3. Clear your browser cache **<== IMPORTANT** 34 | 35 | 4. Open your browser's and wait until the page is **fully** loaded 36 | 37 | 5. Open your browser's console and write `_unusedjs.report()` 38 | 39 | 40 | 41 | ## Results 42 | 43 | Results are displayed in the console: 44 | 45 | ![screenshot](https://raw.githubusercontent.com/gmetais/unusedjs/master/doc/ouput.png) 46 | 47 | Why "(for the moment)"? Because the score might change if some more JS gets executed in the page. 48 | 49 | 50 | ## Inspect what code is unused for one file (experimental) 51 | 52 | ```js 53 | _unusedjs.file() 54 | ``` 55 | There are stille some bugs with very large files, especially when minified on one very long line. **Best displayed on Chrome or Safari.** 56 | 57 | ![screenshot](https://raw.githubusercontent.com/gmetais/unusedjs/master/doc/file.png) 58 | 59 | 60 | 61 | ## Troubleshooting / FAQ 62 | 63 | #### _unusedjs is not defined 64 | That means no JS file was instrumented by the proxy. Make sure the page you are testing is not HTTPS. Make sure the page loads at least 1 script and it's not over HTTPS. Make sure the proxy is still running and is not displaying errors. Then make sure you configured correctly your browser's proxy. 65 | 66 | #### The proxy fails with an error 67 | I did not debug this error yet. Can you? 68 | 69 | #### The console doesn't display the colors 70 | Your browser may not be compatible with console.log styling. 71 | 72 | #### The page loads slower 73 | Yes. The JS files are instrumented by the proxy and this step is slow. And it's not parallelized. Don't forget to kill the tool when you're done, otherwise you might experience a sloooooow surfing session. 74 | 75 | #### Inlined scripts are not analyzed 76 | Sorry. 77 | 78 | #### Does XX% of unused code mean I should remove it? 79 | It's not so easy. It can be some code that's not executed at page load, triggered by a user action for example. If it's a library (such as jQuery), removing the unused parts is pretty hazardous. 80 | 81 | #### My trouble / question is not listed here 82 | Just open a GitHub issue :) 83 | 84 | 85 | ## What's next with this tool? 86 | 87 | For the moment it's just a quick proof of concept. Tell me if the tool is interesting, because here are some ideas for the future: 88 | - automatically make the measures on domContentLoaded, domContentLoadedEnd and domComplete (can help defer scripts after the critical path). 89 | - automatic launch in PhantomJS configured with the proxy. 90 | - a service worker that does this automatically (on localhost). 91 | 92 | 93 | ## Author 94 | 95 | Gaël Métais. I'm a webperf freelance. Follow me on Twitter [@gaelmetais](https://twitter.com/gaelmetais), I tweet mostly about Web Performances. 96 | 97 | I can also help your company about Web Performances, visit [my website](https://www.gaelmetais.com). 98 | -------------------------------------------------------------------------------- /bin/startProxy.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var istanbul = require('istanbul'); 6 | var HttpProxy = require('../lib/httpProxy'); 7 | 8 | var instrumenter = new istanbul.Instrumenter({ 9 | embedSource: true, 10 | noAutoWrap: true 11 | }); 12 | 13 | var proxy = new HttpProxy(); 14 | var port = 3838; 15 | var template = fs.readFileSync(path.resolve(__dirname, '..', 'lib', 'template.js'), 'utf8'); 16 | 17 | proxy.start(port, function(bodyBuffer, transferWeight, ungzipedWeight, url, index) { 18 | var newBody = template; 19 | 20 | var instrumentedJS = bodyBuffer.toString(); 21 | 22 | try { 23 | instrumentedJS = instrumenter.instrumentSync(instrumentedJS, url); 24 | 25 | var tokenFinder = /^\nvar (__cov_([^\s]+)) = \(Function\('return this'\)\)\(\);/.exec(instrumentedJS); 26 | var randomToken = tokenFinder[1]; 27 | 28 | newBody = newBody.split('__ISTANBUL_FAIL').join('false'); 29 | newBody = newBody.split('__ISTANBUL_TOKEN__').join(randomToken); 30 | 31 | } catch(err) { 32 | console.log('File %s could not be instrumented', url); 33 | 34 | newBody = newBody.split('__ISTANBUL_FAIL').join('true'); 35 | newBody = newBody.split('__ISTANBUL_TOKEN__').join('""'); 36 | } 37 | 38 | newBody = newBody.split('__FILE_NAME__').join(url); 39 | newBody = newBody.split('__BODY__').join(instrumentedJS); 40 | 41 | return new Buffer(newBody); 42 | }); 43 | 44 | console.log('You can now configure your browser to use the proxy "localhost:3838"'); -------------------------------------------------------------------------------- /doc/file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmetais/unusedjs/9195d5fa5de1e641b150eec600d8afdeddff5c6e/doc/file.png -------------------------------------------------------------------------------- /doc/ouput.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmetais/unusedjs/9195d5fa5de1e641b150eec600d8afdeddff5c6e/doc/ouput.png -------------------------------------------------------------------------------- /lib/httpProxy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A "Man in the middle" proxy, that modifies Javascript files for parsing and execution timings prupose. 3 | */ 4 | 5 | var HttpProxy = function() { 6 | 'use strict'; 7 | 8 | var http = require('http'); 9 | var connect = require('connect'); 10 | var httpProxy = require('http-proxy'); 11 | var url = require('url'); 12 | var zlib = require('zlib'); 13 | 14 | var server = null; 15 | var proxy = null; 16 | var app = null; 17 | var index = 0; 18 | 19 | return { 20 | 21 | /** 22 | * Starts the server and sets a modify function, called for each intercepted script 23 | * 24 | * The following parameters are injected into the function: 25 | * 26 | * @param bodyBuffer {Buffer} A buffer containing the body. You can use bodyBuffer.toString(). 27 | * @param transferWeight {Integer} The size of the script (gziped) in bytes, or null if the script was not gziped. 28 | * @param ungzipedWeight {Integer} The size of the script (ungziped) in bytes. 29 | * @param scriptUrl {String} The url of the script. 30 | * @param index {Integer} A unique integer for the file. 31 | * 32 | * The function must return a Buffer 33 | */ 34 | start: function(port, modifyFn) { 35 | 36 | // The node-http-proxy (https://github.com/nodejitsu/node-http-proxy) 37 | proxy = httpProxy.createProxyServer({}); 38 | 39 | // This is needed to avoid the "socket hang up" error when a request is canceled 40 | proxy.on('error', function (err, req, res) { 41 | res.writeHead(500, {'Content-Type': 'text/plain'}); 42 | res.end('Something went wrong'); 43 | }); 44 | 45 | // The middleware layer 46 | app = connect(); 47 | 48 | // Middleware that intercepts the content body 49 | app.use(function (req, res, next) { 50 | 51 | // Save these functions which will be overriden 52 | var _write = res.write; 53 | var _end = res.end; 54 | var _writeHead = res.writeHead; 55 | 56 | // Save the previously set headers 57 | var _statusCode = null; 58 | var _headers = {}; 59 | 60 | // Response buffer 61 | var buffer = null; 62 | 63 | // Override res.write 64 | res.write = function (data) { 65 | buffer = buffer === null ? data : Buffer.concat([buffer, data]); 66 | }; 67 | 68 | // Override res.end 69 | res.end = function() { 70 | if (!buffer) { 71 | sendResponse(_statusCode, _headers); 72 | return; 73 | } 74 | 75 | if (isJavascript()) { 76 | 77 | if (isGziped()) { 78 | 79 | // Unzip the file 80 | zlib.gunzip(buffer, function(err, dezipped) { 81 | if (err) { 82 | // Ungzip failed 83 | console.log('Could not ungzip ' + req.url); 84 | sendResponse(_statusCode, _headers, buffer); 85 | } else { 86 | 87 | // Ungzip succeed 88 | // Transform! 89 | var newBody = modifyFn(dezipped, buffer.length, dezipped.length, req.url, index); 90 | 91 | // Re-gzip 92 | zlib.gzip(newBody, function(err, zipped) { 93 | if (err) { 94 | // Oops, re-zip failed, WTF?!? 95 | console.log('Could not re-gzip something that was correctly ungziped!!!'); 96 | console.log(err); 97 | sendResponse(_statusCode, _headers, buffer); 98 | } else { 99 | _headers['content-length'] = zipped.length; 100 | sendResponse(_statusCode, _headers, zipped); 101 | } 102 | }); 103 | } 104 | }); 105 | 106 | } else { 107 | // Not gziped 108 | // Transform! 109 | var newBody = modifyFn(buffer, buffer.length, buffer.length, req.url, index); 110 | _headers['content-length'] = newBody.length; 111 | sendResponse(_statusCode, _headers, newBody); 112 | 113 | } 114 | 115 | index ++; 116 | } else { 117 | // Not a JS file 118 | sendResponse(_statusCode, _headers, buffer); 119 | } 120 | }; 121 | // Override res.writeHead 122 | res.writeHead = function(statusCode) { 123 | _statusCode = statusCode; 124 | }; 125 | // Override res.setHeader 126 | res.setHeader = function(name, value) { 127 | _headers[name.toLowerCase()] = value; 128 | }; 129 | 130 | function sendResponse(statusCode, headers, body) { 131 | // Override cache headers, we don't want no cache! 132 | headers['cache-control'] = 'no-cache, no-store, must-revalidate'; 133 | headers['pragma'] = 'no-cache'; 134 | headers['expires'] = '0'; 135 | 136 | _writeHead.call(res, statusCode, headers); 137 | if (body) { 138 | _write.call(res, body); 139 | } 140 | _end.call(res); 141 | } 142 | 143 | function isJavascript() { 144 | var contentType = (_headers['content-type'] || '').split(';').shift().toLowerCase(); 145 | return contentType == 'application/x-javascript' || 146 | contentType == 'application/javascript' || 147 | contentType == 'text/javascript'; 148 | } 149 | 150 | function isGziped() { 151 | var contentEncoding = _headers['content-encoding']; 152 | return contentEncoding && contentEncoding === 'gzip'; 153 | } 154 | 155 | next(); 156 | }); 157 | 158 | // Proxify the requests 159 | app.use(function(req, res) { 160 | var reqUrl = url.parse(req.url); 161 | var target = reqUrl.protocol + '//' + reqUrl.host; 162 | proxy.web(req, res, { target: target }); 163 | }); 164 | 165 | server = http.createServer(app).listen(port); 166 | console.log('Proxy listening on port ' + port); 167 | }, 168 | 169 | stop: function() { 170 | proxy.close(); 171 | server.close(); 172 | console.log('Proxy stopped'); 173 | } 174 | }; 175 | }; 176 | 177 | module.exports = HttpProxy; -------------------------------------------------------------------------------- /lib/template.js: -------------------------------------------------------------------------------- 1 | /* THIS SCRIPT WAS TRANSFORMED BY UnusedJSProxy */ 2 | 3 | __BODY__; 4 | 5 | 6 | if (!window._unusedjs) { 7 | window._unusedjs = (function() { 8 | 9 | var coveredFiles = []; 10 | var failedFiles = []; 11 | 12 | var CONSOLE_STYLE_OK = 'color: #090;'; 13 | var CONSOLE_STYLE_KO = 'color: #B00;'; 14 | var CONSOLE_STYLE_FAIL = 'background: #900; color: #FFF;'; 15 | var CONSOLE_STYLE_GLOBAL_OK = 'font-size: 1.5em; font-weight: bold; background: #090; color: #FFF;'; 16 | var CONSOLE_STYLE_GLOBAL_KO = 'font-size: 1.5em; font-weight: bold; background: #B00; color: #FFF;'; 17 | var CONSOLE_STYLE_USED = 'background: auto;'; 18 | var CONSOLE_STYLE_UNUSED = 'background: #F99;'; 19 | 20 | return { 21 | newCoveredFile: function(istanbulToken) { 22 | coveredFiles.push(istanbulToken); 23 | }, 24 | 25 | coverageFailed: function(istanbulToken) { 26 | coveredFiles.push(istanbulToken); 27 | }, 28 | 29 | report: function() { 30 | var globalAllCount = 0; 31 | var globalOkCount = 0; 32 | 33 | coveredFiles.forEach(function(file, fileIndex) { 34 | 35 | var allCount = 0; 36 | var okCount = 0; 37 | 38 | for (var statement in file.s) { 39 | allCount ++; 40 | if (file.s[statement]) { 41 | okCount++; 42 | } 43 | } 44 | 45 | if (allCount > 0) { 46 | var percent = Math.round(okCount * 1000 / allCount) / 10; 47 | var style = (percent >= 50) ? CONSOLE_STYLE_OK : CONSOLE_STYLE_KO; 48 | console.log('File %d: %c%s of unused code for: %s (over %d statements)', fileIndex + 1, style, '' + (100 - percent).toFixed(1) + '%', file.path, allCount); 49 | 50 | globalAllCount += allCount; 51 | globalOkCount += okCount; 52 | } else { 53 | console.log('No statement detected in file: %s', file.path); 54 | } 55 | }); 56 | 57 | failedFiles.forEach(function(fileName) { 58 | console.log('%c Code coverage failed for: %s', CONSOLE_STYLE_FAIL, fileName); 59 | }); 60 | 61 | if (globalAllCount > 0) { 62 | var globalPercent = Math.round(globalOkCount * 1000 / globalAllCount) / 10; 63 | var globalStyle = (globalPercent >= 50) ? CONSOLE_STYLE_GLOBAL_OK : CONSOLE_STYLE_GLOBAL_KO; 64 | console.log('%c Total: %s of unused code (for the moment)', globalStyle, '' + (100 - globalPercent).toFixed(1) + '%'); 65 | } else { 66 | console.log('%c Global used code could not be calculated', CONSOLE_STYLE_GLOBAL_KO); 67 | } 68 | }, 69 | 70 | file: function(fileIndex) { 71 | var i; 72 | 73 | // Return a string version of a number with leading 0 74 | function pad(num, size) { 75 | var s = num+""; 76 | while (s.length < size) s = "0" + s; 77 | return s; 78 | } 79 | 80 | if (!fileIndex) { 81 | console.warn('File index needed. Example: _unusedjs.showFile(2)'); 82 | return; 83 | } 84 | 85 | var index = fileIndex - 1; 86 | if (!coveredFiles[index]) { 87 | console.warn('File index %d not found', fileIndex); 88 | return; 89 | } 90 | 91 | var file = coveredFiles[index]; 92 | console.log('%c File coverage for %s', CONSOLE_STYLE_GLOBAL_OK, file.path); 93 | 94 | 95 | // Grab all unused statements by line of code 96 | var lines = {}; 97 | for (var statement in file.s) { 98 | if (file.s[statement] === 0) { 99 | var startLine = file.statementMap[statement].start.line; 100 | var endLine = file.statementMap[statement].end.line; 101 | for (i = startLine; i <= endLine ; i++) { 102 | if (!lines[i]) { 103 | lines[i] = { 104 | containsUnusedStatements: true, 105 | unusedStatements: [statement] 106 | }; 107 | } else { 108 | lines[i].unusedStatements.push(statement); 109 | } 110 | } 111 | } 112 | } 113 | 114 | // For each line containing unused statements, find the unused chars 115 | for (var lineIndex in lines) { 116 | var lineNumber = parseInt(lineIndex, 10); 117 | var line = lines[lineNumber]; 118 | 119 | // Array pre-filled with 0 120 | var unusedChars = []; 121 | for (i = 0 ; i < file.code[lineNumber - 1].length ; i++) { 122 | unusedChars[i] = 0; 123 | } 124 | 125 | line.unusedStatements.forEach(function(statementNumber) { 126 | var statement = file.statementMap[statementNumber]; 127 | var startColumn = 0; 128 | var endColumn = Infinity; 129 | 130 | if (statement.start.line < lineNumber) { 131 | startColumn = 0; 132 | } else { 133 | startColumn = statement.start.column; 134 | } 135 | 136 | if (statement.end.line > lineNumber) { 137 | endColumn = file.code[lineNumber - 1].length; 138 | } else { 139 | endColumn = statement.end.column; 140 | } 141 | 142 | for (i = startColumn ; i < endColumn ; i++) { 143 | unusedChars[i] = 1; 144 | } 145 | 146 | }); 147 | 148 | lines[lineIndex].unusedChars = unusedChars; 149 | } 150 | 151 | // Display code 152 | var allCodeLines = ['']; 153 | var consoleLogArgs = []; 154 | var maxDigits = file.code.length.toString().length; 155 | 156 | file.code.forEach(function(lineOfCode, index) { 157 | var out = '%c' + pad(index + 1, maxDigits) + '. '; 158 | consoleLogArgs.push(CONSOLE_STYLE_USED); 159 | 160 | if (lines[index + 1] && lineOfCode.length > 0) { 161 | 162 | var unusedChars = lines[index + 1].unusedChars; 163 | var remainingLineOfCode = lineOfCode; 164 | var transformedLineOfCode = ''; 165 | var colorsArgs = []; 166 | 167 | while (unusedChars.length > 0) { 168 | var isUnused = unusedChars[unusedChars.length - 1]; 169 | var lastChange = unusedChars.lastIndexOf(1 - isUnused) + 1; 170 | unusedChars = unusedChars.slice(0, lastChange); 171 | transformedLineOfCode = '%c' + remainingLineOfCode.substr(lastChange) + transformedLineOfCode; 172 | colorsArgs.push(isUnused ? CONSOLE_STYLE_UNUSED : CONSOLE_STYLE_USED); 173 | remainingLineOfCode = remainingLineOfCode.substr(0, lastChange); 174 | } 175 | 176 | out += transformedLineOfCode; 177 | colorsArgs.reverse(); 178 | consoleLogArgs = consoleLogArgs.concat(colorsArgs); 179 | 180 | } else { 181 | out += lineOfCode; 182 | } 183 | 184 | allCodeLines.push(out); 185 | }); 186 | 187 | consoleLogArgs.unshift(allCodeLines.join('\n')); 188 | console.log.apply(console, consoleLogArgs); 189 | } 190 | }; 191 | }()); 192 | } 193 | 194 | if (__ISTANBUL_FAIL === true) { 195 | window._unusedjs.coverageFailed('__FILE_NAME__'); 196 | } else { 197 | window._unusedjs.newCoveredFile(__ISTANBUL_TOKEN__); 198 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unusedjs", 3 | "version": "0.2.2", 4 | "description": "Discover the percent of unused JS on a page with this simple browser proxy", 5 | "bin": { 6 | "unused-js-proxy": "bin/startProxy.js" 7 | }, 8 | "main": "lib/httpProxy.js", 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/gmetais/unusedjs" 15 | }, 16 | "keywords": [ 17 | "performance", 18 | "webperf", 19 | "javascript", 20 | "proxy" 21 | ], 22 | "author": "Gael Metais", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/gmetais/unusedjs/issues" 26 | }, 27 | "homepage": "https://github.com/gmetais/unusedjs", 28 | "dependencies": { 29 | "connect": "^3.4.1", 30 | "http-proxy": "^1.14.0", 31 | "istanbul": "^0.4.3" 32 | } 33 | } 34 | --------------------------------------------------------------------------------