weightChecker.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. /*
  2. * Redownloading every files after Phantomas has finished
  3. * Checks weight and every kind of compression
  4. *
  5. */
  6. var debug = require('debug')('ylt:weightChecker');
  7. var Q = require('q');
  8. var http = require('http');
  9. var zlib = require('zlib');
  10. var async = require('async');
  11. var request = require('request');
  12. var imageOptimizer = require('./imageOptimizer');
  13. var fileMinifier = require('./fileMinifier');
  14. var gzipCompressor = require('./gzipCompressor');
  15. var WeightChecker = function() {
  16. var MAX_PARALLEL_DOWNLOADS = 10;
  17. var REQUEST_TIMEOUT = 15000; // 15 seconds
  18. // This function will re-download every asset and check if it could be optimized
  19. function recheckAllFiles(data) {
  20. var startTime = Date.now();
  21. debug('Redownload started');
  22. var deferred = Q.defer();
  23. var requestsList = JSON.parse(data.toolsResults.phantomas.offenders.requestsList);
  24. delete data.toolsResults.phantomas.metrics.requestsList;
  25. delete data.toolsResults.phantomas.offenders.requestsList;
  26. var httpAuth = null;
  27. if (data.params && data.params.options && data.params.options.authUser && data.params.options.authPass) {
  28. httpAuth = {
  29. username: data.params.options.authUser,
  30. password: data.params.options.authPass
  31. };
  32. }
  33. // Transform every request into a download function with a callback when done
  34. var redownloadList = requestsList.map(function(entry) {
  35. return function(callback) {
  36. redownloadEntry(entry, httpAuth)
  37. .then(imageOptimizer.optimizeImage)
  38. .then(fileMinifier.minifyFile)
  39. .then(gzipCompressor.compressFile)
  40. .then(function(newEntry) {
  41. debug('File %s - Redownloaded, optimized, minified, compressed: done', entry.url);
  42. callback(null, newEntry);
  43. })
  44. .fail(function(err) {
  45. callback(err);
  46. });
  47. };
  48. });
  49. // Lanch all redownload functions and wait for completion
  50. async.parallelLimit(redownloadList, MAX_PARALLEL_DOWNLOADS, function(err, results) {
  51. if (err) {
  52. debug(err);
  53. deferred.reject(err);
  54. } else {
  55. debug('All files checked');
  56. endTime = Date.now();
  57. debug('Redownload took %d ms', endTime - startTime);
  58. var metrics = {};
  59. var offenders = {};
  60. // Count requests
  61. offenders.totalRequests = listRequestsByType(results);
  62. metrics.totalRequests = offenders.totalRequests.total;
  63. // Remove unwanted requests (redirections, about:blank)
  64. results = results.filter(function(result) {
  65. return (result !== null && result.weightCheck && result.weightCheck.bodySize > 0);
  66. });
  67. // Total weight
  68. offenders.totalWeight = listRequestWeight(results);
  69. metrics.totalWeight = offenders.totalWeight.totalWeight;
  70. // Image compression
  71. offenders.imageOptimization = listImageNotOptimized(results);
  72. metrics.imageOptimization = offenders.imageOptimization.totalGain;
  73. // File minification
  74. offenders.fileMinification = listFilesNotMinified(results);
  75. metrics.fileMinification = offenders.fileMinification.totalGain;
  76. // Gzip compression
  77. offenders.gzipCompression = listFilesNotGzipped(results);
  78. metrics.gzipCompression = offenders.gzipCompression.totalGain;
  79. // Small requests
  80. offenders.smallRequests = listSmallRequests(results);
  81. metrics.smallRequests = offenders.smallRequests.total;
  82. data.toolsResults.weightChecker = {
  83. metrics: metrics,
  84. offenders: offenders
  85. };
  86. deferred.resolve(data);
  87. }
  88. });
  89. return deferred.promise;
  90. }
  91. function listRequestWeight(requests) {
  92. var results = {
  93. totalWeight: 0,
  94. byType: {
  95. html: {
  96. totalWeight: 0,
  97. requests: []
  98. },
  99. css: {
  100. totalWeight: 0,
  101. requests: []
  102. },
  103. js: {
  104. totalWeight: 0,
  105. requests: []
  106. },
  107. json: {
  108. totalWeight: 0,
  109. requests: []
  110. },
  111. image: {
  112. totalWeight: 0,
  113. requests: []
  114. },
  115. video: {
  116. totalWeight: 0,
  117. requests: []
  118. },
  119. webfont: {
  120. totalWeight: 0,
  121. requests: []
  122. },
  123. other: {
  124. totalWeight: 0,
  125. requests: []
  126. }
  127. }
  128. };
  129. requests.forEach(function(req) {
  130. var weight = ((typeof req.weightCheck.bodySize === 'number') ? req.weightCheck.bodySize + req.weightCheck.headersSize : req.contentLength) || 0;
  131. var type = req.type || 'other';
  132. type = (results.byType[type]) ? type : 'other';
  133. results.totalWeight += weight;
  134. results.byType[type].totalWeight += weight;
  135. results.byType[type].requests.push({
  136. url: req.url,
  137. weight: weight
  138. });
  139. });
  140. return results;
  141. }
  142. function listImageNotOptimized(requests) {
  143. var results = {
  144. totalGain: 0,
  145. images: []
  146. };
  147. requests.forEach(function(req) {
  148. if (req.weightCheck.bodySize > 0 && imageOptimizer.entryTypeCanBeOptimized(req) && req.weightCheck.isOptimized === false) {
  149. var before = req.weightCheck.afterCompression || req.weightCheck.bodySize;
  150. var after = req.weightCheck.afterOptimizationAndCompression || req.weightCheck.optimized;
  151. var gain = before - after;
  152. if (gain > 200) {
  153. results.totalGain += gain;
  154. results.images.push({
  155. url: req.url,
  156. original: req.weightCheck.bodySize,
  157. isCompressed: req.weightCheck.isCompressed,
  158. afterCompression: req.weightCheck.afterCompression,
  159. afterOptimizationAndCompression: req.weightCheck.afterOptimizationAndCompression,
  160. lossless: req.weightCheck.lossless,
  161. lossy: req.weightCheck.lossy,
  162. gain: gain
  163. });
  164. }
  165. }
  166. });
  167. return results;
  168. }
  169. function listFilesNotMinified(requests) {
  170. var results = {
  171. totalGain: 0,
  172. files: []
  173. };
  174. requests.forEach(function(req) {
  175. if (req.weightCheck.bodySize > 0 && fileMinifier.entryTypeCanBeMinified(req) && req.weightCheck.isOptimized === false) {
  176. var before = req.weightCheck.afterCompression || req.weightCheck.bodySize;
  177. var after = req.weightCheck.afterOptimizationAndCompression || req.weightCheck.optimized;
  178. var gain = before - after;
  179. if (gain > 200) {
  180. results.totalGain += gain;
  181. results.files.push({
  182. url: req.url,
  183. original: req.weightCheck.bodySize,
  184. isCompressed: req.weightCheck.isCompressed,
  185. afterCompression: req.weightCheck.afterCompression,
  186. afterOptimizationAndCompression: req.weightCheck.afterOptimizationAndCompression,
  187. optimized: req.weightCheck.optimized,
  188. gain: gain
  189. });
  190. }
  191. }
  192. });
  193. return results;
  194. }
  195. function listFilesNotGzipped(requests) {
  196. var results = {
  197. totalGain: 0,
  198. files: []
  199. };
  200. requests.forEach(function(req) {
  201. if (req.weightCheck.uncompressedSize && req.weightCheck.isCompressed === false && req.weightCheck.afterCompression) {
  202. var gain = req.weightCheck.uncompressedSize - req.weightCheck.afterCompression;
  203. results.totalGain += gain;
  204. results.files.push({
  205. url: req.url,
  206. original: req.weightCheck.uncompressedSize,
  207. gzipped: req.weightCheck.afterCompression,
  208. gain: gain
  209. });
  210. }
  211. });
  212. return results;
  213. }
  214. function listRequestsByType(requests) {
  215. var results = {
  216. total: 0,
  217. byType: {
  218. html: [],
  219. css: [],
  220. js: [],
  221. json: [],
  222. image: [],
  223. video: [],
  224. webfont: [],
  225. other: []
  226. }
  227. };
  228. requests.forEach(function(req) {
  229. if (req.url !== 'about:blank') {
  230. var type = req.type || 'other';
  231. type = (results.byType[type]) ? type : 'other';
  232. results.byType[type].push(req.url);
  233. results.total ++;
  234. }
  235. });
  236. return results;
  237. }
  238. function listSmallRequests(requests) {
  239. var results = {
  240. total: 0,
  241. byType: {
  242. css: [],
  243. js: [],
  244. image: []
  245. }
  246. };
  247. requests.forEach(function(req) {
  248. if (req.weightCheck.bodySize > 0 && req.weightCheck.bodySize < 2048) {
  249. if (req.isCSS || req.isJS || req.isImage) {
  250. results.byType[req.type].push({
  251. url: req.url,
  252. size: req.weightCheck.bodySize
  253. });
  254. results.total ++;
  255. }
  256. }
  257. });
  258. return results;
  259. }
  260. function redownloadEntry(entry, httpAuth) {
  261. var deferred = Q.defer();
  262. function downloadError(message) {
  263. debug('Could not download %s Error: %s', entry.url, message);
  264. entry.weightCheck = {
  265. message: message
  266. };
  267. deferred.resolve(entry);
  268. }
  269. // Not downloaded again but will be counted in totalWeight
  270. function notDownloadableFile(message) {
  271. entry.weightCheck = {
  272. message: message
  273. };
  274. deferred.resolve(entry);
  275. }
  276. // Not counted in totalWeight
  277. function unwantedFile(message) {
  278. debug(message);
  279. deferred.resolve(entry);
  280. }
  281. if (entry.method !== 'GET') {
  282. notDownloadableFile('only downloading GET');
  283. return deferred.promise;
  284. }
  285. if (entry.status !== 200) {
  286. unwantedFile('only downloading requests with status code 200');
  287. return deferred.promise;
  288. }
  289. if (entry.url === 'about:blank') {
  290. unwantedFile('not downloading about:blank');
  291. return deferred.promise;
  292. }
  293. debug('Downloading %s', entry.url);
  294. // Always add a gzip header before sending, in case the server listens to it
  295. var reqHeaders = entry.requestHeaders;
  296. reqHeaders['Accept-Encoding'] = 'gzip, deflate';
  297. var requestOptions = {
  298. method: entry.method,
  299. url: entry.url,
  300. headers: reqHeaders,
  301. timeout: REQUEST_TIMEOUT
  302. };
  303. // Basic auth
  304. if (httpAuth) {
  305. requestOptions.auth = {
  306. user: httpAuth.username,
  307. pass: httpAuth.password,
  308. sendImmediately: false // Tries a first time without auth, wait for a 401 error before resending
  309. };
  310. }
  311. download(requestOptions, entry.contentType, function(error, result) {
  312. if (error) {
  313. if (error.code === 'ETIMEDOUT') {
  314. downloadError('timeout after ' + REQUEST_TIMEOUT + 'ms');
  315. } else {
  316. downloadError('error while downloading: ' + error.code);
  317. }
  318. return;
  319. }
  320. debug('%s downloaded correctly', entry.url);
  321. entry.weightCheck = result;
  322. deferred.resolve(entry);
  323. });
  324. return deferred.promise;
  325. }
  326. // Inspired by https://github.com/cvan/fastHAR-api/blob/10cec585/app.js
  327. function download(requestOptions, contentType, callback) {
  328. var statusCode;
  329. request(requestOptions)
  330. .on('response', function(res) {
  331. // Raw headers were added in NodeJS v0.12
  332. // (https://github.com/joyent/node/issues/4844), but let's
  333. // reconstruct them for backwards compatibility.
  334. var rawHeaders = ('HTTP/' + res.httpVersion + ' ' + res.statusCode +
  335. ' ' + http.STATUS_CODES[res.statusCode] + '\r\n');
  336. Object.keys(res.headers).forEach(function(headerKey) {
  337. rawHeaders += headerKey + ': ' + res.headers[headerKey] + '\r\n';
  338. });
  339. rawHeaders += '\r\n';
  340. var uncompressedSize = 0; // size after uncompression
  341. var bodySize = 0; // bytes size over the wire
  342. var body = ''; // plain text body (after uncompressing gzip/deflate)
  343. var isCompressed = false;
  344. function tally() {
  345. if (statusCode !== 200) {
  346. callback({code: statusCode});
  347. return;
  348. }
  349. var result = {
  350. body: body,
  351. headersSize: Buffer.byteLength(rawHeaders, 'utf8'),
  352. bodySize: bodySize,
  353. isCompressed: isCompressed,
  354. uncompressedSize: uncompressedSize
  355. };
  356. callback(null, result);
  357. }
  358. switch (res.headers['content-encoding']) {
  359. case 'gzip':
  360. var gzip = zlib.createGunzip();
  361. gzip.on('data', function (data) {
  362. body += data;
  363. uncompressedSize += data.length;
  364. }).on('end', function () {
  365. isCompressed = true;
  366. tally();
  367. }).on('error', function(err) {
  368. debug('Error while decoding %s', requestOptions.url);
  369. debug(err);
  370. callback(err);
  371. });
  372. res.on('data', function (data) {
  373. bodySize += data.length;
  374. }).pipe(gzip);
  375. break;
  376. case 'deflate':
  377. res.setEncoding('utf8');
  378. var deflate = zlib.createInflate();
  379. deflate.on('data', function (data) {
  380. body += data;
  381. uncompressedSize += data.length;
  382. }).on('end', function () {
  383. isCompressed = true;
  384. tally();
  385. }).on('error', function(err) {
  386. debug('Error while decoding %s', requestOptions.url);
  387. debug(err);
  388. callback(err);
  389. });
  390. res.on('data', function (data) {
  391. bodySize += data.length;
  392. }).pipe(deflate);
  393. break;
  394. default:
  395. if (contentType === 'image/jpeg' || contentType === 'image/png') {
  396. res.setEncoding('binary');
  397. }
  398. res.on('data', function (data) {
  399. body += data;
  400. uncompressedSize += data.length;
  401. bodySize += data.length;
  402. }).on('end', function () {
  403. tally();
  404. });
  405. break;
  406. }
  407. })
  408. .on('response', function(response) {
  409. statusCode = response.statusCode;
  410. })
  411. .on('error', function(err) {
  412. debug('Error while downloading %s', requestOptions.url);
  413. debug(err);
  414. callback(err);
  415. });
  416. }
  417. return {
  418. recheckAllFiles: recheckAllFiles,
  419. listRequestWeight: listRequestWeight,
  420. redownloadEntry: redownloadEntry,
  421. download: download
  422. };
  423. };
  424. module.exports = new WeightChecker();