UNPKG

18.3 kBJavaScriptView Raw
1"use strict";
2
3Object.defineProperty(exports, "__esModule", {
4 value: true
5});
6exports.default = exports.PAGE_UNLOADED_DURING_EXECUTION_ERROR_MESSAGE = void 0;
7
8var _cssTree = _interopRequireDefault(require("css-tree"));
9
10var _debug = _interopRequireDefault(require("debug"));
11
12var _pruneNonCriticalSelectors = _interopRequireDefault(require("./browser-sandbox/pruneNonCriticalSelectors"));
13
14var _replacePageCss = _interopRequireDefault(require("./browser-sandbox/replacePageCss"));
15
16var _postformatting = _interopRequireDefault(require("./postformatting"));
17
18var _selectorsProfile = _interopRequireDefault(require("./selectors-profile"));
19
20var _nonMatchingMediaQueryRemover = _interopRequireDefault(require("./non-matching-media-query-remover"));
21
22function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
23
24function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }
25
26function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(source, true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(source).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
27
28function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
29
30function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); }
31
32function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); }
33
34function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); }
35
36function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; return arr2; } }
37
38const debuglog = (0, _debug.default)('penthouse:core');
39const PUPPETEER_PAGE_UNLOADED_DURING_EXECUTION_ERROR_REGEX = /(Cannot find context with specified id|Execution context was destroyed)/;
40const PAGE_UNLOADED_DURING_EXECUTION_ERROR_MESSAGE = 'PAGE_UNLOADED_DURING_EXECUTION: Critical css generation script could not be executed.\n\nThis can happen if Penthouse was killed during execution, OR otherwise most commonly if the page navigates away after load, via setting window.location, meta tag refresh directive or similar. For the critical css generation to work the loaded page must stay: remove any redirects or move them to the server. You can also disable them on your end just for the critical css generation, for example via a query parameter.';
41exports.PAGE_UNLOADED_DURING_EXECUTION_ERROR_MESSAGE = PAGE_UNLOADED_DURING_EXECUTION_ERROR_MESSAGE;
42
43function blockinterceptedRequests(interceptedRequest) {
44 const isJsRequest = /\.js(\?.*)?$/.test(interceptedRequest.url());
45
46 if (isJsRequest) {
47 interceptedRequest.abort();
48 } else {
49 interceptedRequest.continue();
50 }
51}
52
53function loadPage(page, url, timeout, pageLoadSkipTimeout, allowedResponseCode) {
54 debuglog('page load start');
55 let waitingForPageLoad = true;
56 let loadPagePromise = page.goto(url);
57
58 if (pageLoadSkipTimeout) {
59 loadPagePromise = Promise.race([loadPagePromise, new Promise(resolve => {
60 // _abort_ page load after X time,
61 // in order to deal with spammy pages that keep sending non-critical requests
62 // (tracking etc), which would otherwise never load.
63 // With JS disabled it just shouldn't take that many seconds to load what's needed
64 // for critical viewport.
65 setTimeout(() => {
66 if (waitingForPageLoad) {
67 debuglog('page load waiting ABORTED after ' + pageLoadSkipTimeout / 1000 + 's. ');
68 resolve();
69 }
70 }, pageLoadSkipTimeout);
71 })]);
72 }
73
74 return loadPagePromise.then(response => {
75 if (typeof allowedResponseCode !== 'undefined') {
76 checkResponseStatus(allowedResponseCode, response);
77 }
78
79 waitingForPageLoad = false;
80 debuglog('page load DONE');
81 });
82}
83
84function checkResponseStatus(allowedResponseCode, response) {
85 var errorMessage;
86
87 if (typeof allowedResponseCode === 'number' && response.status() !== allowedResponseCode) {
88 errorMessage = `Server response status ${response.status()} isn't matching allowedResponseCode: ${allowedResponseCode}.`;
89 } else if (typeof allowedResponseCode === 'object' && allowedResponseCode.constructor.name === 'RegExp' && !response.status().toString().match(allowedResponseCode)) {
90 errorMessage = `Server response status ${response.status()} isn't matching allowedResponseCode: ${allowedResponseCode.toString()}.`;
91 } else if (typeof allowedResponseCode === 'function' && !allowedResponseCode.call(this, response)) {
92 errorMessage = `Server response status ${response.status()} isn't matching allowedResponseCode.`;
93 }
94
95 if (errorMessage) {
96 throw new Error(errorMessage);
97 }
98}
99
100function setupBlockJsRequests(page) {
101 page.on('request', blockinterceptedRequests);
102 return page.setRequestInterception(true);
103}
104
105async function astFromCss({
106 cssString,
107 strict
108}) {
109 // breaks puppeteer
110 const css = cssString.replace(/￿/g, '\f042');
111 const parsingErrors = [];
112 debuglog('parse ast START');
113
114 const ast = _cssTree.default.parse(css, {
115 onParseError: error => parsingErrors.push(error.formattedMessage)
116 });
117
118 debuglog(`parse ast DONE (with ${parsingErrors.length} errors)`);
119
120 if (parsingErrors.length && strict === true) {
121 // NOTE: only informing about first error, even if there were more than one.
122 const parsingErrorMessage = parsingErrors[0];
123 throw new Error(`AST parser (css-tree) found ${parsingErrors.length} errors in CSS.
124 Breaking because in strict mode.
125 The first error was:
126 ` + parsingErrorMessage);
127 }
128
129 return ast;
130}
131
132async function preparePage({
133 page,
134 pagePromise,
135 width,
136 height,
137 cookies,
138 userAgent,
139 customPageHeaders,
140 blockJSRequests,
141 cleanupAndExit,
142 getHasExited
143}) {
144 let reusedPage;
145
146 try {
147 const pagePromiseResult = await pagePromise;
148 page = pagePromiseResult.page;
149 reusedPage = pagePromiseResult.reused;
150 } catch (e) {
151 debuglog('unexpected: could not get an open browser page' + e);
152 return;
153 } // we already exited while page was opening, stop execution
154 // (strict mode ast css parsing erros)
155
156
157 if (getHasExited()) {
158 return;
159 }
160
161 debuglog('open page ready in browser'); // We set the viewport size in the browser when it launches,
162 // and then re-use it for each page (to avoid extra work).
163 // Only if later pages use a different viewport size do we need to
164 // update it here.
165
166 let setViewportPromise = Promise.resolve();
167 const currentViewport = page.viewport();
168
169 if (currentViewport.width !== width || currentViewport.height !== height) {
170 setViewportPromise = page.setViewport({
171 width,
172 height
173 }).then(() => debuglog('viewport size updated'));
174 }
175
176 const setUserAgentPromise = page.setUserAgent(userAgent).then(() => debuglog('userAgent set'));
177 let setCustomPageHeadersPromise = Promise.resolve();
178
179 if (customPageHeaders && Object.keys(customPageHeaders).length) {
180 try {
181 setCustomPageHeadersPromise = page.setExtraHTTPHeaders(customPageHeaders).then(() => debuglog('customPageHeaders set:' + JSON.stringify(customPageHeaders)));
182 } catch (e) {
183 debuglog('failed setting extra http headers: ' + e);
184 }
185 }
186
187 let setCookiesPromise = Promise.resolve();
188
189 if (cookies) {
190 try {
191 var _page;
192
193 setCookiesPromise = (_page = page).setCookie.apply(_page, _toConsumableArray(cookies)).then(() => debuglog('cookie(s) set: ' + JSON.stringify(cookies)));
194 } catch (e) {
195 debuglog('failed to set cookies: ' + e);
196 }
197 } // assumes the page was already configured from previous call!
198
199
200 if (reusedPage) {
201 return Promise.all([setViewportPromise, setUserAgentPromise, setCustomPageHeadersPromise, setCookiesPromise]).then(() => {
202 debuglog('preparePage DONE');
203 return page;
204 });
205 } // disable Puppeteer navigation timeouts;
206 // Penthouse tracks these internally instead.
207
208
209 page.setDefaultNavigationTimeout(0);
210 let blockJSRequestsPromise;
211
212 if (blockJSRequests) {
213 // NOTE: with JS disabled we cannot use JS timers inside page.evaluate
214 // (setTimeout, setInterval), however requestAnimationFrame works.
215 blockJSRequestsPromise = Promise.all([page.setJavaScriptEnabled(false), setupBlockJsRequests(page)]).then(() => {
216 debuglog('blocking js requests DONE');
217 });
218 }
219
220 page.on('error', error => {
221 debuglog('page error: ' + error);
222 cleanupAndExit({
223 error
224 });
225 });
226 page.on('console', msg => {
227 const text = msg.text ? typeof msg.text === 'function' ? msg.text() : msg.text : msg; // pass through log messages
228 // - the ones sent by penthouse for debugging has 'debug: ' prefix.
229
230 if (/^debug: /.test(text)) {
231 debuglog(text.replace(/^debug: /, ''));
232 }
233 });
234 debuglog('page event listeners set');
235 return Promise.all([setViewportPromise, setUserAgentPromise, setCustomPageHeadersPromise, setCookiesPromise, blockJSRequestsPromise]).then(() => {
236 debuglog('preparePage DONE');
237 return page;
238 });
239}
240
241async function grabPageScreenshot({
242 type,
243 page,
244 screenshots,
245 screenshotExtension,
246 debuglog
247}) {
248 const path = screenshots.basePath + `-${type}` + screenshotExtension;
249 debuglog(`take ${type} screenshot, START`);
250 return page.screenshot(_objectSpread({}, screenshots, {
251 path
252 })).then(() => debuglog(`take ${type} screenshot DONE, path: ${path}`));
253}
254
255async function pruneNonCriticalCssLauncher({
256 pagePromise,
257 url,
258 cssString,
259 width,
260 height,
261 forceInclude,
262 forceExclude,
263 strict,
264 userAgent,
265 renderWaitTime,
266 timeout,
267 pageLoadSkipTimeout,
268 blockJSRequests,
269 customPageHeaders,
270 cookies,
271 screenshots,
272 propertiesToRemove,
273 maxEmbeddedBase64Length,
274 keepLargerMediaQueries,
275 maxElementsToCheckPerSelector,
276 unstableKeepBrowserAlive,
277 allowedResponseCode
278}) {
279 let _hasExited = false; // hacky to get around _hasExited only available in the scope of this function
280
281 const getHasExited = () => _hasExited;
282
283 const takeScreenshots = screenshots && screenshots.basePath;
284 const screenshotExtension = takeScreenshots && screenshots.type === 'jpeg' ? '.jpg' : '.png'; // NOTE: would need a refactor to killTimeout logic to be able to remove promise here.
285
286 /* eslint-disable no-async-promise-executor */
287
288 return new Promise(async (resolve, reject) => {
289 /* eslint-enable no-async-promise-executor */
290 debuglog('Penthouse core start');
291 let page = null;
292 let killTimeout = null;
293
294 async function cleanupAndExit({
295 error,
296 returnValue
297 }) {
298 if (_hasExited) {
299 return;
300 }
301
302 debuglog('cleanupAndExit');
303 _hasExited = true;
304 clearTimeout(killTimeout);
305
306 if (error) {
307 return reject(error);
308 }
309
310 if (page) {
311 const resetPromises = []; // reset page headers and cookies,
312 // since we re-use the page
313
314 if (customPageHeaders && Object.keys(customPageHeaders).length) {
315 try {
316 resetPromises.push(page.setExtraHTTPHeaders({}).then(() => debuglog('customPageHeaders reset')));
317 } catch (e) {
318 debuglog('failed resetting extra http headers: ' + e);
319 }
320 } // reset cookies
321
322
323 if (cookies && cookies.length) {
324 try {
325 var _page2;
326
327 resetPromises.push((_page2 = page).deleteCookie.apply(_page2, _toConsumableArray(cookies)).then(() => debuglog('cookie(s) reset: ')));
328 } catch (e) {
329 debuglog('failed to reset cookies: ' + e);
330 }
331 }
332
333 await Promise.all(resetPromises);
334 }
335
336 return resolve(returnValue);
337 }
338
339 killTimeout = setTimeout(() => {
340 cleanupAndExit({
341 error: new Error('Penthouse timed out after ' + timeout / 1000 + 's. ')
342 });
343 }, timeout); // 1. start preparing a browser page (tab) [NOT BLOCKING]
344
345 const updatedPagePromise = preparePage({
346 page,
347 pagePromise,
348 width,
349 height,
350 userAgent,
351 cookies,
352 customPageHeaders,
353 blockJSRequests,
354 cleanupAndExit,
355 getHasExited
356 }); // 2. parse ast
357 // -> [BLOCK FOR] AST parsing
358
359 let ast;
360
361 try {
362 ast = await astFromCss({
363 cssString,
364 strict
365 });
366 } catch (e) {
367 cleanupAndExit({
368 error: e
369 });
370 return;
371 } // 3. Further process the ast [BLOCKING]
372 // Strip out non matching media queries.
373 // Need to be done before buildSelectorProfile;
374 // (very fast but could be done together/in parallel in future)
375
376
377 (0, _nonMatchingMediaQueryRemover.default)(ast, width, height, keepLargerMediaQueries);
378 debuglog('stripped out non matching media queries'); // -> [BLOCK FOR] page preparation
379
380 page = await updatedPagePromise;
381
382 if (!page) {
383 cleanupAndExit({
384 error: 'Could not open page in browser'
385 });
386 return;
387 } // load the page (slow) [NOT BLOCKING]
388
389
390 const loadPagePromise = loadPage(page, url, timeout, pageLoadSkipTimeout, allowedResponseCode); // turn css to formatted selectorlist [NOT BLOCKING]
391
392 debuglog('turn css to formatted selectorlist START');
393 const buildSelectorProfilePromise = (0, _selectorsProfile.default)(ast, forceInclude && forceInclude.length ? forceInclude : null, forceExclude && forceExclude.length ? forceExclude : null).then(res => {
394 debuglog('turn css to formatted selectorlist DONE');
395 return res;
396 }); // -> [BLOCK FOR] page load
397
398 try {
399 await loadPagePromise;
400 } catch (e) {
401 cleanupAndExit({
402 error: e
403 });
404 return;
405 }
406
407 if (!page) {
408 // in case we timed out
409 debuglog('page load TIMED OUT');
410 cleanupAndExit({
411 error: new Error('Page load timed out')
412 });
413 return;
414 }
415
416 if (_hasExited) return; // Penthouse waits for the `load` event to fire
417 // (before loadPagePromise resolves; except for very slow loading pages)
418 // (via default puppeteer page.goto options.waitUntil setting,
419 // https://github.com/GoogleChrome/puppeteer/blob/v1.8.0/docs/api.md#pagegotourl-options)
420 // This means "all of the objects in the document are in the DOM, and all the images...
421 // have finished loading".
422 // This is necessary for Penthouse to know the correct layout of the critical viewport
423 // (well really, we would only need to load the critical viewport.. not possible?)
424 // However, @font-face's can be available later,
425 // and for this reason it can be useful to delay further - if screenshots are used.
426 // For this `renderWaitTime` can be used.
427 // Note: `renderWaitTime` is not a very good name,
428 // and just setting a time is also not the most effective solution to f.e. wait for fonts.
429 // In future probably deprecate and allow for a custom function instead (returning a promise).
430 // -> [BLOCK FOR] renderWaitTime - needs to be done before we take any screenshots
431
432 await new Promise(resolve => {
433 setTimeout(() => {
434 debuglog('waited for renderWaitTime: ' + renderWaitTime);
435 resolve();
436 }, renderWaitTime);
437 }); // take before screenshot (optional) [NOT BLOCKING]
438
439 const beforeScreenshotPromise = takeScreenshots ? grabPageScreenshot({
440 type: 'before',
441 page,
442 screenshots,
443 screenshotExtension,
444 debuglog
445 }) : Promise.resolve(); // -> [BLOCK FOR] css into formatted selectors list with "sourcemap"
446 // latter used to map back to full css rule
447
448 const {
449 selectors,
450 selectorNodeMap
451 } = await buildSelectorProfilePromise;
452
453 if (getHasExited()) {
454 return;
455 } // -> [BLOCK FOR] critical css selector pruning (in browser)
456
457
458 let criticalSelectors;
459
460 try {
461 criticalSelectors = await page.evaluate(_pruneNonCriticalSelectors.default, {
462 selectors,
463 renderWaitTime,
464 maxElementsToCheckPerSelector
465 }).then(criticalSelectors => {
466 debuglog('pruneNonCriticalSelectors done');
467 return criticalSelectors;
468 });
469 } catch (err) {
470 debuglog('pruneNonCriticalSelector threw an error: ' + err);
471 const errorDueToPageUnloaded = PUPPETEER_PAGE_UNLOADED_DURING_EXECUTION_ERROR_REGEX.test(err);
472 cleanupAndExit({
473 error: errorDueToPageUnloaded ? new Error(PAGE_UNLOADED_DURING_EXECUTION_ERROR_MESSAGE) : err
474 });
475 return;
476 }
477
478 if (getHasExited()) {
479 return;
480 } // -> [BLOCK FOR] clean up final ast for critical css
481
482
483 debuglog('AST cleanup START'); // NOTE: this function mutates the AST
484
485 (0, _postformatting.default)({
486 ast,
487 selectorNodeMap,
488 criticalSelectors,
489 propertiesToRemove,
490 maxEmbeddedBase64Length
491 });
492 debuglog('AST cleanup DONE'); // -> [BLOCK FOR] generate final critical css from critical ast
493
494 const css = _cssTree.default.generate(ast);
495
496 debuglog('generated CSS from AST'); // take after screenshot (optional) [BLOCKING]
497
498 if (takeScreenshots) {
499 // wait for the before screenshot, before start modifying the page
500 await beforeScreenshotPromise;
501 debuglog('inline critical styles for after screenshot');
502 await page.evaluate(_replacePageCss.default, {
503 css
504 }).then(() => {
505 return grabPageScreenshot({
506 type: 'after',
507 page,
508 screenshots,
509 screenshotExtension,
510 debuglog
511 });
512 });
513 }
514
515 debuglog('generateCriticalCss DONE');
516 cleanupAndExit({
517 returnValue: css
518 });
519 });
520}
521
522var _default = pruneNonCriticalCssLauncher;
523exports.default = _default;
\No newline at end of file