mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-15 18:19:47 +00:00
Adds single-pass prerendering via a new prerender-loader
This commit is contained in:
@@ -1,64 +1,214 @@
|
|||||||
const path = require('path');
|
|
||||||
const vm = require('vm');
|
|
||||||
|
|
||||||
module.exports = function (content) {
|
|
||||||
const jsdom = require('jsdom');
|
const jsdom = require('jsdom');
|
||||||
const preact = require('preact');
|
const os = require('os');
|
||||||
const renderToString = require('preact-render-to-string');
|
const util = require('util');
|
||||||
|
const path = require('path');
|
||||||
|
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');
|
||||||
|
|
||||||
this.cacheable && this.cacheable();
|
const FILENAME = 'ssr-bundle.js';
|
||||||
|
|
||||||
|
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 callback = this.async();
|
||||||
|
|
||||||
// const dom = new jsdom.JSDOM(`<!DOCTYPE html><html><head></head><body></body></html>`, {
|
prerender(this, options, inject)
|
||||||
const dom = new jsdom.JSDOM(content, {
|
.then(output => {
|
||||||
|
callback(null, outputFilter(output));
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
callback(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
if (!inject && options.template) {
|
||||||
|
const loadModule = util.promisify(loaderContext.loadModule);
|
||||||
|
const source = await loadModule('!!raw-loader!' + path.resolve(context, options.template));
|
||||||
|
options.templateContent = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
includeNodeLocations: false,
|
||||||
runScripts: 'outside-only'
|
runScripts: 'outside-only'
|
||||||
});
|
});
|
||||||
const { window } = dom;
|
const { window } = dom;
|
||||||
const { document } = window;
|
|
||||||
|
|
||||||
// console.log(content);
|
// Find the placeholder node for injection & remove it
|
||||||
|
const injectPlaceholder = window.document.getElementById('PRERENDER_INJECT');
|
||||||
const root = document.getElementById('app');
|
if (injectPlaceholder) {
|
||||||
this.loadModule(path.join(__dirname, 'client-boot.js'), (err, source) => {
|
injectParent = injectPlaceholder.parentNode;
|
||||||
if (err) return callback(err);
|
injectPlaceholder.remove();
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// let html = `
|
// These are missing from JSDOM
|
||||||
// window = {};
|
window.requestAnimationFrame = setTimeout;
|
||||||
// module.exports = ${JSON.stringify(parts[0])} + require("preact-render-to-string")(require("app-entry-point")) + ${JSON.stringify(parts[1])}`;
|
window.cancelAnimationFrame = clearTimeout;
|
||||||
let html = `module.exports = ${JSON.stringify(parts[0])} + require("preact-render-to-string")(require("app-entry-point")) + ${JSON.stringify(parts[1])}`;
|
|
||||||
callback(null, html);
|
// 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
1
global.d.ts
vendored
@@ -1,4 +1,5 @@
|
|||||||
declare const __webpack_public_path__: string;
|
declare const __webpack_public_path__: string;
|
||||||
|
declare const PRERENDER: boolean;
|
||||||
|
|
||||||
declare interface NodeModule {
|
declare interface NodeModule {
|
||||||
hot: any;
|
hot: any;
|
||||||
|
|||||||
@@ -60,7 +60,9 @@
|
|||||||
"fork-ts-checker-webpack-plugin": "^0.4.1",
|
"fork-ts-checker-webpack-plugin": "^0.4.1",
|
||||||
"html-webpack-plugin": "^3.0.6",
|
"html-webpack-plugin": "^3.0.6",
|
||||||
"if-env": "^1.0.4",
|
"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",
|
"mini-css-extract-plugin": "^0.3.0",
|
||||||
"node-sass": "^4.7.2",
|
"node-sass": "^4.7.2",
|
||||||
"nwmatcher": "^1.4.4",
|
"nwmatcher": "^1.4.4",
|
||||||
@@ -70,6 +72,7 @@
|
|||||||
"preload-webpack-plugin": "github:GoogleChromeLabs/preload-webpack-plugin",
|
"preload-webpack-plugin": "github:GoogleChromeLabs/preload-webpack-plugin",
|
||||||
"pretty-bytes": "^4.0.2",
|
"pretty-bytes": "^4.0.2",
|
||||||
"progress-bar-webpack-plugin": "^1.11.0",
|
"progress-bar-webpack-plugin": "^1.11.0",
|
||||||
|
"raw-loader": "^0.5.1",
|
||||||
"sass-loader": "^6.0.7",
|
"sass-loader": "^6.0.7",
|
||||||
"script-ext-html-webpack-plugin": "^2.0.1",
|
"script-ext-html-webpack-plugin": "^2.0.1",
|
||||||
"source-map-loader": "^0.2.3",
|
"source-map-loader": "^0.2.3",
|
||||||
|
|||||||
@@ -10,14 +10,6 @@
|
|||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" prerender></div>
|
<div id="app"></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" /> -->
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,7 @@ import './style';
|
|||||||
import App from './components/app';
|
import App from './components/app';
|
||||||
|
|
||||||
// Find the outermost Element in our server-rendered HTML structure.
|
// 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:
|
// "attach" the client-side rendering to it, updating the DOM in-place instead of replacing:
|
||||||
root = render(<App />, document.body, root);
|
root = render(<App />, document.body, root);
|
||||||
@@ -26,7 +26,7 @@ if (process.env.NODE_ENV === 'development') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @todo SSR */
|
/** @todo Async SSR if we need it */
|
||||||
// if (typeof module==='object') {
|
// export default async () => {
|
||||||
// module.exports = app;
|
// // render here, then resolve to a string of HTML (or null to serialize the document)
|
||||||
// }
|
// }
|
||||||
|
|||||||
@@ -165,15 +165,14 @@ module.exports = function(_, env) {
|
|||||||
]),
|
]),
|
||||||
|
|
||||||
// For now we're not doing SSR.
|
// For now we're not doing SSR.
|
||||||
new HtmlWebpackPlugin({
|
new HtmlPlugin({
|
||||||
filename: path.join(__dirname, 'build/index.html'),
|
filename: path.join(__dirname, 'build/index.html'),
|
||||||
template: '!!ejs-loader!src/index.html',
|
template: '!' + path.join(__dirname, 'config/prerender-loader') + '?string' + (isProd ? '' : '&disabled') + '!src/index.html',
|
||||||
// template: '!!'+path.join(__dirname, 'config/prerender-loader')+'!src/index.html',
|
|
||||||
minify: isProd && {
|
minify: isProd && {
|
||||||
collapseWhitespace: true,
|
collapseWhitespace: true,
|
||||||
removeScriptTypeAttributes: true,
|
removeScriptTypeAttributes: true,
|
||||||
removeRedundantAttributes: true,
|
|
||||||
removeStyleLinkTypeAttributes: true,
|
removeStyleLinkTypeAttributes: true,
|
||||||
|
removeRedundantAttributes: true,
|
||||||
removeComments: true
|
removeComments: true
|
||||||
},
|
},
|
||||||
manifest: readJson('./src/manifest.json'),
|
manifest: readJson('./src/manifest.json'),
|
||||||
|
|||||||
Reference in New Issue
Block a user