@ewizardjs/prerenderer
Version:
Fast, flexible, framework-agnostic prerendering for sites and SPAs.
185 lines (146 loc) • 6.29 kB
JavaScript
const promiseLimit = require('promise-limit')
const puppeteer = require('puppeteer')
const getInstance = require('./get-instance')
const { URL } = require('url')
const FONTS = /\.(ttf|eot|otf|woff(2)?)(\?[a-z0-9=&.]+)?$/i
const waitForRender = function (options) {
options = options || {}
return new Promise((resolve, reject) => {
// Render when an event fires on the document.
if (options.renderAfterDocumentEvent) {
if (window['__PRERENDER_STATUS'] && window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED) resolve()
document.addEventListener(options.renderAfterDocumentEvent, () => resolve())
// Render after a certain number of milliseconds.
} else if (options.renderAfterTime) {
setTimeout(() => resolve(), options.renderAfterTime)
// Default: Render immediately after page content loads.
} else {
resolve()
}
})
}
class PuppeteerRenderer {
constructor(rendererOptions) {
this._puppeteer = null
this._rendererOptions = rendererOptions || {}
if (this._rendererOptions.maxConcurrentRoutes == null) this._rendererOptions.maxConcurrentRoutes = 0
if (this._rendererOptions.preloadFonts) this._fonts = new Set()
if (this._rendererOptions.inject && !this._rendererOptions.injectProperty) {
this._rendererOptions.injectProperty = '__PRERENDER_INJECTED'
}
}
async initialize() {
try {
// Workaround for Linux SUID Sandbox issues.
if (process.platform === 'linux') {
if (!this._rendererOptions.args) this._rendererOptions.args = []
if (this._rendererOptions.args.indexOf('--no-sandbox') === -1) {
this._rendererOptions.args.push('--no-sandbox')
this._rendererOptions.args.push('--disable-setuid-sandbox')
}
}
this._puppeteer = await getInstance(this._rendererOptions);
const { incognitoContext = true } = this._rendererOptions;
this._context = incognitoContext ? await this._puppeteer.createIncognitoBrowserContext() : this._puppeteer;
} catch (e) {
console.error(e)
console.error('[Prerenderer - PuppeteerRenderer] Unable to start Puppeteer')
// Re-throw the error so it can be handled further up the chain. Good idea or not?
throw e
}
return this._puppeteer
}
async handleRequestInterception(page, baseURL) {
await page.setRequestInterception(true)
page.on('request', req => {
// Skip third party requests if needed.
if (this._rendererOptions.skipThirdPartyRequests) {
if (!req.url().startsWith(baseURL)) {
req.abort()
return
}
}
if (this._rendererOptions.preloadFonts) {
const font = req.url()
if (FONTS.test(font)) {
const { pathname } = new URL(font)
this._fonts.add(pathname)
}
}
req.continue()
})
}
async renderRoutes(routes, Prerenderer) {
const rootOptions = Prerenderer.getOptions()
const options = this._rendererOptions
const limiter = promiseLimit(this._rendererOptions.maxConcurrentRoutes)
const pagePromises = Promise.all(
routes.map(
(route, index) => limiter(
async () => {
const page = await this._context.newPage();
if (options.consoleHandler) {
page.on('console', message => options.consoleHandler(route, message))
}
if (options.inject) {
await page.evaluateOnNewDocument(`(function () { window['${options.injectProperty}'] = ${JSON.stringify(options.inject)}; })();`)
}
const baseURL = `http://localhost:${rootOptions.server.port}`
// Allow setting viewport widths and such.
if (options.viewport) await page.setViewport(options.viewport)
await this.handleRequestInterception(page, baseURL)
// Hack just in-case the document event fires before our main listener is added.
if (options.renderAfterDocumentEvent) {
page.evaluateOnNewDocument(function (options) {
window['__PRERENDER_STATUS'] = {}
document.addEventListener(options.renderAfterDocumentEvent, () => {
window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED = true
})
}, this._rendererOptions)
}
const navigationOptions = (options.navigationOptions) ? { waituntil: 'networkidle0', ...options.navigationOptions } : { waituntil: 'networkidle0' };
await page.goto(`${baseURL}${route}`, navigationOptions);
// Wait for some specific element exists
const { renderAfterElementExists } = this._rendererOptions
if (renderAfterElementExists && typeof renderAfterElementExists === 'string') {
await page.waitForSelector(renderAfterElementExists)
}
await page.evaluateHandle('document.fonts.ready');
// Once this completes, it's safe to capture the page contents.
await page.evaluate(waitForRender, this._rendererOptions)
if (this._rendererOptions.preloadFonts && this._fonts.size > 0) {
await this.preloadFonts(page)
}
if (typeof this._rendererOptions.onBeforeDone === 'function') {
await this._rendererOptions.onBeforeDone(page, route)
}
const result = {
originalRoute: route,
route: await page.evaluate('window.location.pathname'),
html: await page.content()
}
await page.close();
return result
}
)
)
)
return pagePromises
}
async preloadFonts(page) {
const headHandle = await page.$('head')
await page.evaluate((head, fonts) => {
const fontsHTML = fonts.map(font => `<link rel="preload" href="${font}" as="font"/>`).join('')
head.insertAdjacentHTML('afterbegin', fontsHTML)
}, headHandle, Array.from(this._fonts))
await headHandle.dispose()
}
async destroy() {
await this._context.close();
if (this._rendererOptions.browserUrl)
await this._puppeteer.disconnect();
else
await this._puppeteer.close();
}
}
module.exports = PuppeteerRenderer