Adds single-pass prerendering via a new prerender-loader

This commit is contained in:
Jason Miller
2018-04-17 14:04:55 -04:00
parent be8fae10f8
commit 3ba0a5a22a
6 changed files with 218 additions and 73 deletions

View File

@@ -1,64 +1,214 @@
const jsdom = require('jsdom');
const os = require('os');
const util = require('util');
const path = require('path');
const vm = require('vm');
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 SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
const DefinePlugin = require('webpack').DefinePlugin;
const MemoryFs = require('memory-fs');
module.exports = function (content) {
const jsdom = require('jsdom');
const preact = require('preact');
const renderToString = require('preact-render-to-string');
const FILENAME = 'ssr-bundle.js';
this.cacheable && this.cacheable();
const PRERENDER_REG = /\{\{prerender(?::\s*([^}]+)\s*)?\}\}/;
module.exports = 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.templateContent = content;
}
const callback = this.async();
// const dom = new jsdom.JSDOM(`<!DOCTYPE html><html><head></head><body></body></html>`, {
const dom = new jsdom.JSDOM(content, {
includeNodeLocations: false,
runScripts: 'outside-only'
});
const { window } = dom;
const { document } = window;
prerender(this, options, inject)
.then(output => {
callback(null, outputFilter(output));
})
.catch(err => {
console.error(err);
callback(err);
});
};
// console.log(content);
async function prerender (loaderContext, options, inject) {
const parentCompilation = loaderContext._compilation;
const parentCompiler = rootCompiler(parentCompilation.compiler);
const request = loaderContext.request;
const context = parentCompiler.options.context || process.cwd();
const entry = './' + ((options.entry && [].concat(options.entry).pop().trim()) || path.relative(context, parentCompiler.options.entry));
const root = document.getElementById('app');
this.loadModule(path.join(__dirname, 'client-boot.js'), (err, source) => {
if (err) return callback(err);
console.log(source);
let mod = eval(source);
let props = {};
// console.log(mod);
let vnode = preact.createElement(mod, props);
let frag = document.createElement('div');
frag.innerHTML = renderToString(vnode);
root.parentNode.replaceChild(frag.firstChild, root);
let html = dom.serialize();
callback(null, html);
// return html = `module.exports = ${JSON.stringify(html)}`;
// return 'module.exports = ' + JSON.stringify(content).replace(/\{\{PRERENDER\}\}/gi, `" + require("preact-render-to-string")(require("app-entry-point")) + "`);
});
// global.window = global;
// global.document = {};
// return 'module.exports = ' + JSON.stringify(content).replace(/\{\{PRERENDER\}\}/gi, `" + require("preact-render-to-string")(require("app-entry-point")) + "`);
/*
let callback = this.async();
let parts = content.split(/\{\{prerender\}\}/gi);
if (parts.length<2) {
// callback(null, `module.exports = ${JSON.stringify(content)}`);
callback(null, content);
return;
if (!inject && options.template) {
const loadModule = util.promisify(loaderContext.loadModule);
const source = await loadModule('!!raw-loader!' + path.resolve(context, options.template));
options.templateContent = source;
}
// let html = `
// window = {};
// module.exports = ${JSON.stringify(parts[0])} + require("preact-render-to-string")(require("app-entry-point")) + ${JSON.stringify(parts[1])}`;
let html = `module.exports = ${JSON.stringify(parts[0])} + require("preact-render-to-string")(require("app-entry-point")) + ${JSON.stringify(parts[1])}`;
callback(null, html);
*/
};
const outputOptions = {
// fix for plugins not using outputfilesystem
path: os.tmpdir(),
filename: FILENAME
};
// Only copy over mini-extract-text-plugin (excluding it breaks extraction entirely)
const plugins = (parentCompiler.options.plugins || []).filter(c => /MiniCssExtractPlugin/i.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.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}}`)
new SingleEntryPlugin(context, entry, undefined).apply(compiler);
// 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('prerender-loader', addChildCache);
} else {
compiler.plugin('compilation', addChildCache);
}
const compilation = await runChildCompiler(compiler);
let result = '';
let dom, injectParent;
if (compilation.assets[compilation.options.output.filename]) {
// Get the compiled main bundle
const output = compilation.assets[compilation.options.output.filename].source();
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>'), {
includeNodeLocations: false,
runScripts: 'outside-only'
});
const { window } = dom;
// Find the placeholder node for injection & remove it
const injectPlaceholder = window.document.getElementById('PRERENDER_INJECT');
if (injectPlaceholder) {
injectParent = injectPlaceholder.parentNode;
injectPlaceholder.remove();
}
// These are missing from JSDOM
window.requestAnimationFrame = setTimeout;
window.cancelAnimationFrame = clearTimeout;
// Invoke the SSR bundle within the JSDOM document and grab the exported/returned result
result = window.eval(output + '\nPRERENDER_RESULT') || result;
if (window.PRERENDER_RESULT != null) {
result = window.PRERENDER_RESULT;
}
}
// Deal with ES Module exports (just use the best guess):
if (result && result.__esModule === true) {
result = getBestModuleExport(result);
}
if (typeof result === 'function') {
// @todo any arguments worth passing here?
result = result();
}
// 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 == null && dom) {
result = dom.serialize();
} else if (inject) {
// @todo determine if this is really necessary for the string return case
if (injectParent) {
injectParent.insertAdjacentHTML('beforeend', result || '');
} else {
// Otherwise inject the prerendered HTML into the template
result = options.templateContent.replace(PRERENDER_REG, result || '');
}
}
return result;
}
// Promisified version of compiler.runAsChild() with error hoisting and isolated output/assets
function runChildCompiler (compiler) {
return new Promise((resolve, reject) => {
// runAsChild() merges assets into the parent compilation, we don't want that.
compiler.compile((err, compilation) => {
compiler.parentCompilation.children.push(compilation);
if (err) return reject(err);
if (compilation.errors && compilation.errors.length) {
const errorDetails = compilation.errors.map(error => error.details).join('\n');
return reject(Error('Child compilation failed:\n' + errorDetails));
}
resolve(compilation);
});
});
}
// Crawl up the compiler tree and return the outermost compiler instance
function rootCompiler (compiler) {
while (compiler.parentCompilation && compiler.parentCompilation.compiler) {
compiler = compiler.parentCompilation.compiler;
}
return compiler;
}
// Find the best possible export for an ES Module. Returns `undefined` for no exports.
function getBestModuleExport (exports) {
if (exports.default) {
return exports.default;
}
for (const prop in exports) {
if (prop !== '__esModule') {
return exports[prop];
}
}
}
// Wrap a String up into an ES Module that exports it
const stringToModule = str => 'export default ' + JSON.stringify(str);

1
global.d.ts vendored
View File

@@ -1,4 +1,5 @@
declare const __webpack_public_path__: string;
declare const PRERENDER: boolean;
declare interface NodeModule {
hot: any;

View File

@@ -60,7 +60,9 @@
"fork-ts-checker-webpack-plugin": "^0.4.1",
"html-webpack-plugin": "^3.0.6",
"if-env": "^1.0.4",
"jsdom": "^11.6.2",
"jsdom": "^11.5.1",
"loader-utils": "^1.1.0",
"memory-fs": "^0.4.1",
"mini-css-extract-plugin": "^0.3.0",
"node-sass": "^4.7.2",
"nwmatcher": "^1.4.4",
@@ -70,6 +72,7 @@
"preload-webpack-plugin": "github:GoogleChromeLabs/preload-webpack-plugin",
"pretty-bytes": "^4.0.2",
"progress-bar-webpack-plugin": "^1.11.0",
"raw-loader": "^0.5.1",
"sass-loader": "^6.0.7",
"script-ext-html-webpack-plugin": "^2.0.1",
"source-map-loader": "^0.2.3",

View File

@@ -10,14 +10,6 @@
<link rel="manifest" href="/manifest.json">
</head>
<body>
<div id="app" prerender></div>
<script>
(function(style){
style.rel='stylesheet'
style.href='https://fonts.googleapis.com/icon?family=Material+Icons'
document.head.appendChild(style)
})(document.createElement('link'));
</script>
<!-- <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" /> -->
<div id="app"></div>
</body>
</html>
</html>

View File

@@ -4,7 +4,7 @@ import './style';
import App from './components/app';
// Find the outermost Element in our server-rendered HTML structure.
let root = document.querySelector('[prerender]') || undefined;
let root = document.querySelector('#app') || undefined;
// "attach" the client-side rendering to it, updating the DOM in-place instead of replacing:
root = render(<App />, document.body, root);
@@ -26,7 +26,7 @@ if (process.env.NODE_ENV === 'development') {
});
}
/** @todo SSR */
// if (typeof module==='object') {
// module.exports = app;
/** @todo Async SSR if we need it */
// export default async () => {
// // render here, then resolve to a string of HTML (or null to serialize the document)
// }

View File

@@ -165,15 +165,14 @@ module.exports = function(_, env) {
]),
// For now we're not doing SSR.
new HtmlWebpackPlugin({
new HtmlPlugin({
filename: path.join(__dirname, 'build/index.html'),
template: '!!ejs-loader!src/index.html',
// template: '!!'+path.join(__dirname, 'config/prerender-loader')+'!src/index.html',
template: '!' + path.join(__dirname, 'config/prerender-loader') + '?string' + (isProd ? '' : '&disabled') + '!src/index.html',
minify: isProd && {
collapseWhitespace: true,
removeScriptTypeAttributes: true,
removeRedundantAttributes: true,
removeStyleLinkTypeAttributes: true,
removeRedundantAttributes: true,
removeComments: true
},
manifest: readJson('./src/manifest.json'),