weightChecker.js 18 KB

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