diff --git a/config/prerender-loader.js b/config/prerender-loader.js index 91f0647a..67b8456a 100644 --- a/config/prerender-loader.js +++ b/config/prerender-loader.js @@ -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(`
`, { - 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); - */ -}; \ No newline at end of file + 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 || ''; + dom = new jsdom.JSDOM(tpl.replace(PRERENDER_REG, ''), { + 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); diff --git a/global.d.ts b/global.d.ts index 7b9d325d..c91b407a 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,4 +1,5 @@ declare const __webpack_public_path__: string; +declare const PRERENDER: boolean; declare interface NodeModule { hot: any; diff --git a/package.json b/package.json index 780bc154..791334b4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.html b/src/index.html index dac706e1..d8af6770 100644 --- a/src/index.html +++ b/src/index.html @@ -10,14 +10,6 @@ - - - + -