weightChecker.js 16 KB

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