weightChecker.js 14 KB

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