UNPKG

@febinrasheed/prerender-loader

Version:

Painless universal prerendering for Webpack 5. Works great with html-webpack-plugin.

314 lines (268 loc) 12 kB
/** * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ // import os from 'os'; // import jsdom from 'jsdom'; // import loaderUtils from 'loader-utils'; // import LibraryTemplatePlugin from 'webpack/lib/LibraryTemplatePlugin'; // import NodeTemplatePlugin from 'webpack/lib/node/NodeTemplatePlugin'; // import NodeTargetPlugin from 'webpack/lib/node/NodeTargetPlugin'; // import { DefinePlugin } from 'webpack'; // import MemoryFs from 'memory-fs'; // import { runChildCompiler, getRootCompiler, getBestModuleExport, stringToModule, normalizeEntry } from './util'; // import { applyEntry } from './webpack-util'; const os = require('os'); const jsdom = require('jsdom'); const loaderUtils = require('loader-utils'); const LibraryTemplatePlugin = require('webpack/lib/LibraryTemplatePlugin'); const NodeTemplatePlugin = require('webpack/lib/node/NodeTemplatePlugin'); const NodeTargetPlugin = require('webpack/lib/node/NodeTargetPlugin'); const DefinePlugin = require('webpack/lib/DefinePlugin'); const MemoryFs = require('memory-fs'); const { runChildCompiler, getRootCompiler, getBestModuleExport, stringToModule, normalizeEntry } = require('./util'); const { applyEntry } = require('./webpack-util'); // Used to annotate this plugin's hooks in Tappable invocations const PLUGIN_NAME = 'prerender-loader'; // Internal name used for the output bundle (never written to disk) const FILENAME = 'ssr-bundle.js'; // Searches for fields of the form {{prerender}} or {{prerender:./some/module}} const PRERENDER_REG = /\{\{prerender(?::\s*([^}]+?)\s*)?\}\}/; /** * prerender-loader can be applied to any HTML or JS file with the given options. * @public * @param {Options} options Options to control how Critters inlines CSS. * * @example * // webpack.config.js * module.exports = { * plugins: [ * new HtmlWebpackPlugin({ * // `!!` tells webpack to skip any configured loaders for .html files * // `?string` tells prerender-loader output a JS module exporting the HTML string * template: '!!prerender-loader?string!index.html' * }) * ] * } * * @example * // inline demo: assumes you have html-loader set up: * import prerenderedHtml from '!prerender-loader!./file.html'; */ function PrerenderLoader (content) { const options = loaderUtils.getOptions(this) || {}; const outputFilter = options.as === 'string' || options.string ? stringToModule : String; if (options.disabled === true) { return outputFilter(content); } // When applied to HTML, attempts to inject into a specified {{prerender}} field. // @note: this is only used when the entry module exports a String or function // that resolves to a String, otherwise the whole document is serialized. let inject = false; if (!this.request.match(/\.(js|ts)x?$/i)) { const matches = content.match(PRERENDER_REG); if (matches) { inject = true; options.entry = matches[1] || options.entry; } options.templateContent = content; } const callback = this.async(); prerender(this._compilation, this.request, options, inject, this) .then(output => { callback(null, outputFilter(output)); }) .catch(err => { // console.error(err); callback(err); }); } async function prerender (parentCompilation, request, options, inject, loader) { const parentCompiler = getRootCompiler(parentCompilation.compiler); const context = parentCompiler.options.context || process.cwd(); const customEntry = options.entry && ([].concat(options.entry).pop() || '').trim(); const entry = customEntry ? ('./' + customEntry) : normalizeEntry(context, parentCompiler.options.entry, './'); const outputOptions = { // fix: some plugins ignore/bypass outputfilesystem, so use a temp directory and ignore any writes. path: os.tmpdir(), filename: FILENAME }; // Only copy over allowed plugins (excluding them breaks extraction entirely). const allowedPlugins = /(MiniCssExtractPlugin|ExtractTextPlugin)/i; const plugins = (parentCompiler.options.plugins || []).filter(c => allowedPlugins.test(c.constructor.name)); // Compile to an in-memory filesystem since we just want the resulting bundled code as a string const compiler = parentCompilation.createChildCompiler('prerender', outputOptions, plugins); compiler.context = parentCompiler.context; compiler.outputFileSystem = new MemoryFs(); // Define PRERENDER to be true within the SSR bundle new DefinePlugin({ PRERENDER: 'true' }).apply(compiler); // ... then define PRERENDER to be false within the client bundle new DefinePlugin({ PRERENDER: 'false' }).apply(parentCompiler); // Compile to CommonJS to be executed by Node new NodeTemplatePlugin(outputOptions).apply(compiler); new NodeTargetPlugin().apply(compiler); new LibraryTemplatePlugin('PRERENDER_RESULT', 'var').apply(compiler); // Kick off compilation at our entry module (either the parent compiler's entry or a custom one defined via `{{prerender:entry.js}}`) applyEntry(context, entry, compiler); // NOTE: compilation.cache is deprecated in webpack 5. // All tests appear to pass without setting up a subcache. // What was the purpose of this subcache? // // Set up cache inheritance for the child compiler // const subCache = 'subcache ' + request; // function addChildCache (compilation, data) { // if (compilation.cache) { // if (!compilation.cache[subCache]) compilation.cache[subCache] = {}; // compilation.cache = compilation.cache[subCache]; // } // } // if (compiler.hooks) { // compiler.hooks.compilation.tap(PLUGIN_NAME, addChildCache); // } else { // compiler.plugin('compilation', addChildCache); // } const compilation = await runChildCompiler(compiler); let result; let dom, window, injectParent, injectNextSibling; // A promise-like that never resolves and does not retain references to callbacks. function BrokenPromise () {} BrokenPromise.prototype.then = BrokenPromise.prototype.catch = BrokenPromise.prototype.finally = () => new BrokenPromise(); if (compilation.assets[compilation.options.output.filename]) { // Get the compiled main bundle const output = compilation.assets[compilation.options.output.filename].source(); // @TODO: provide a non-DOM option to allow turning off JSDOM entirely. const tpl = options.templateContent || '<!DOCTYPE html><html><head></head><body></body></html>'; dom = new jsdom.JSDOM(tpl.replace(PRERENDER_REG, '<div id="PRERENDER_INJECT"></div>'), { // suppress console-proxied eval() errors, but keep console proxying virtualConsole: new jsdom.VirtualConsole({ omitJSDOMErrors: false }).sendTo(console), // `url` sets the value returned by `window.location`, `document.URL`... // Useful for routers that depend on the current URL (such as react-router or reach-router) url: options.documentUrl || 'http://localhost', // don't track source locations for performance reasons includeNodeLocations: false, // don't allow inline event handlers & script tag exec runScripts: 'outside-only' }); window = dom.window; // Find the placeholder node for injection & remove it const injectPlaceholder = window.document.getElementById('PRERENDER_INJECT'); if (injectPlaceholder) { injectParent = injectPlaceholder.parentNode; injectNextSibling = injectPlaceholder.nextSibling; injectPlaceholder.remove(); } // These are missing from JSDOM let counter = 0; window.requestAnimationFrame = () => ++counter; window.cancelAnimationFrame = () => { }; // Never prerender Custom Elements: by skipping registration, we get only the Light DOM which is desirable. window.customElements = { define () {}, get () {}, upgrade () {}, whenDefined: () => new BrokenPromise() }; // Fake MessagePort window.MessagePort = function () { (this.port1 = new window.EventTarget()).postMessage = () => {}; (this.port2 = new window.EventTarget()).postMessage = () => {}; }; // Never matches window.matchMedia = () => ({ addListener () {} }); // Never register ServiceWorkers if (!window.navigator) window.navigator = {}; window.navigator.serviceWorker = { register: () => new BrokenPromise() }; // When DefinePlugin isn't sufficient window.PRERENDER = true; // Inject a require shim window.require = moduleId => { const asset = compilation.assets[moduleId.replace(/^\.?\//g, '')]; if (!asset) { try { return require(moduleId); } catch (e) { throw Error(`Error: Module not found. attempted require("${moduleId}")`); } } const mod = { exports: {} }; window.eval(`(function(exports, module, require){\n${asset.source()}\n})`)(mod.exports, mod, window.require); return mod.exports; }; // Invoke the SSR bundle within the JSDOM document and grab the exported/returned result result = window.eval(output + '\nPRERENDER_RESULT'); } // Deal with ES Module exports (just use the best guess): if (result && typeof result === 'object') { result = getBestModuleExport(result); } if (typeof result === 'function') { result = result(options.params || null); } // The entry can export or return a Promise in order to perform fully async prerendering: if (result && result.then) { result = await result; } // Returning or resolving to `null` / `undefined` defaults to serializing the whole document. // Note: this pypasses `inject` because the document is already derived from the template. if (result !== undefined && options.templateContent) { const template = window.document.createElement('template'); template.innerHTML = result || ''; const content = template.content || template; const parent = injectParent || window.document.body; let child; while ((child = content.firstChild)) { parent.insertBefore(child, injectNextSibling || null); } } else if (inject) { // Otherwise inject the prerendered HTML into the template return options.templateContent.replace(PRERENDER_REG, result || ''); } // dom.serialize() doesn't properly serialize HTML appended to document.body. // return `<!DOCTYPE ${window.document.doctype.name}>${window.document.documentElement.outerHTML}`; let serialized = dom.serialize(); if (!/^<!DOCTYPE /mi.test(serialized)) { serialized = `<!DOCTYPE html>${serialized}`; } return serialized; // // Returning or resolving to `null` / `undefined` defaults to serializing the whole document. // // Note: this pypasses `inject` because the document is already derived from the template. // if (result == null && dom) { // // result = dom.serialize(); // } else if (inject) { // // @TODO determine if this is really worthwhile/necessary for the string return case // if (injectParent || options.templateContent) { // console.log(injectParent.outerHTML); // (injectParent || document.body).insertAdjacentHTML('beforeend', result || ''); // // result = dom.serialize(); // } else { // // Otherwise inject the prerendered HTML into the template // return options.templateContent.replace(PRERENDER_REG, result || ''); // } // } // return dom.serialize(); // return result; } module.exports = PrerenderLoader;