weightChecker.js 16 KB

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