diff --git a/packages/cli/lib/commands/build.js b/packages/cli/lib/commands/build.js index 44a4b304d..fb91815c0 100644 --- a/packages/cli/lib/commands/build.js +++ b/packages/cli/lib/commands/build.js @@ -1,8 +1,9 @@ const rimraf = require('rimraf'); const { resolve } = require('path'); const { promisify } = require('util'); -const { isDir, error } = require('../util'); +const { isDir, error, warn } = require('../util'); const runWebpack = require('../lib/webpack/run-webpack'); +const fastPrerender = require('../lib/fast-render'); const { validateArgs } = require('./validate-args'); const toBool = val => val === void 0 || (val === 'false' ? false : val); @@ -84,6 +85,10 @@ const options = [ name: '-v, --verbose', description: 'Verbose output', }, + { + name: '--experimental-fast-rendering', + description: 'Experimental pre-rendering. Highly unstable!', + }, ]; async function command(src, argv) { @@ -113,6 +118,13 @@ async function command(src, argv) { if (argv.json) { await runWebpack.writeJsonStats(stats); } + + if (argv['experimental-fast-rendering']) { + warn( + 'You have enabled experimental and unstable fast rendering. You might have to adjust template.html according to the new API.' + ); + await fastPrerender(argv, stats); + } } module.exports = { diff --git a/packages/cli/lib/lib/fast-render/index.js b/packages/cli/lib/lib/fast-render/index.js new file mode 100644 index 000000000..dec5f6cdd --- /dev/null +++ b/packages/cli/lib/lib/fast-render/index.js @@ -0,0 +1,92 @@ +const { info, warn } = require('../../util'); +const { join, resolve } = require('path'); +const { Worker } = require('worker_threads'); +const { writeFile, mkdir } = require('../../fs'); +const { PRERENDER_DATA_FILE_NAME } = require('../constants'); +const pool = require('./pool'); + +module.exports = async function fastPrerender( + { src, dest, cwd, prerenderUrls }, + stats +) { + let pages = [{ url: '/' }]; + if (prerenderUrls) { + try { + let result = require(resolve(cwd, prerenderUrls)); + if (typeof result.default !== 'undefined') { + result = result.default(); + } + if (typeof result === 'function') { + result = await result(); + } + if (typeof result === 'string') { + result = JSON.parse(result); + } + if (result instanceof Array) { + pages = result; + } + } catch (error) { + warn('Failed to load prerenderUrls file, using default!\n'); + } + } + + // eslint-disable-next-line no-console + console.log('\n\n'); + info( + `Prerendering ${pages.length} page${ + pages.length > 0 && 's' + } from ${prerenderUrls}.` + ); + const pageData = {}; + const renderedContents = await pool( + pages.map(data => { + pageData[data.url] = data; + return { + src, + dest, + cwd, + webpack: { + publicPath: stats.compilation.outputOptions.publicPath, + assets: Object.keys(stats.compilation.assets), + }, + data, + }; + }), + ({ src, dest, cwd, webpack, data }) => { + return new Promise(resolve => { + const worker = new Worker(join(__dirname, 'renderer.js'), { + workerData: { + src, + dest, + cwd, + webpack, + data, + }, + }); + worker.once('message', async preRenderContent => { + worker.terminate(); + resolve(preRenderContent); + }); + }); + } + ); + renderedContents.forEach(async preRenderedContent => { + const dirPath = preRenderedContent.url.endsWith('.html') + ? preRenderedContent.url.substring( + 0, + preRenderedContent.url.lastIndexOf('/') + ) + : preRenderedContent.url; + const filePath = preRenderedContent.url.endsWith('.html') + ? preRenderedContent.url + : join(preRenderedContent.url, 'index.html'); + await mkdir(join(dest, dirPath), { + recursive: true, + }); + await writeFile(join(dest, filePath), preRenderedContent.content); + await writeFile( + join(dest, dirPath, PRERENDER_DATA_FILE_NAME), + JSON.stringify(pageData[preRenderedContent.url]) + ); + }); +}; diff --git a/packages/cli/lib/lib/fast-render/pool.js b/packages/cli/lib/lib/fast-render/pool.js new file mode 100644 index 000000000..37899ad06 --- /dev/null +++ b/packages/cli/lib/lib/fast-render/pool.js @@ -0,0 +1,25 @@ +const { cpus } = require('os'); +const resolved = Promise.resolve(); +module.exports = async function pool( + items, + iteratorFn, + concurrency = cpus().length +) { + const itemsLength = items.length; + const returnable = []; + const executing = []; + for (const item of items) { + const promise = resolved.then(() => iteratorFn(item, items)); + returnable.push(promise); + if (concurrency <= itemsLength) { + const execute = promise.then(() => + executing.splice(executing.indexOf(execute), 1) + ); + executing.push(execute); + if (executing.length >= concurrency) { + await Promise.race(executing); + } + } + } + return Promise.all(returnable); +}; diff --git a/packages/cli/lib/lib/fast-render/renderer.js b/packages/cli/lib/lib/fast-render/renderer.js new file mode 100644 index 000000000..057a49564 --- /dev/null +++ b/packages/cli/lib/lib/fast-render/renderer.js @@ -0,0 +1,72 @@ +const { isMainThread, parentPort, workerData } = require('worker_threads'); +const { readFile } = require('../../fs'); +const { join } = require('path'); +const prerender = require('../webpack/prerender'); +const ejs = require('ejs'); +const { minify } = require('html-minifier'); + +async function render(src, dest, cwd, webpack, data) { + const { url, title, ...routeData } = data; + const templateSrc = await readFile(join(src, 'template.html'), 'utf-8'); + const manifest = await readFile(join(dest, 'manifest.json'), 'utf-8'); + const headEndTemplate = await readFile( + join(__dirname, '..', '..', 'resources', 'head-end-modern.ejs'), + 'utf-8' + ); + const bodyEndTemplate = await readFile( + join(__dirname, '..', '..', 'resources', 'body-end-modern.ejs'), + 'utf-8' + ); + const options = { + url, + manifest, + ssr: () => { + const params = { + ...data, + CLI_DATA: { + preRenderData: data, + }, + }; + return prerender({ cwd, dest, src }, params); + }, + CLI_DATA: { url, ...routeData }, + webpack, + }; + const htmlWebpackPlugin = { + options: { + ...options, + title: title || manifest.name || manifest.short_name || 'Preact App', + }, + }; + + const headEnd = ejs.render(headEndTemplate, { + options, + htmlWebpackPlugin, + }); + + const bodyEnd = ejs.render(bodyEndTemplate, { + options, + htmlWebpackPlugin, + }); + const template = templateSrc + .replace(/<%[=]?\s+preact\.title\s+%>/, '<%= options.title %>') + .replace(/<%\s+preact\.headEnd\s+%>/, headEnd) + .replace(/<%\s+preact\.bodyEnd\s+%>/, bodyEnd); + parentPort.postMessage({ + url, + content: minify( + ejs.render(template, { + options, + htmlWebpackPlugin, + }), + { + collapseWhitespace: true, + } + ), + }); +} + +if (!isMainThread) { + const { src, dest, cwd, webpack, data } = workerData; + render(src, dest, cwd, webpack, data); +} diff --git a/packages/cli/lib/lib/webpack/render-html-plugin.js b/packages/cli/lib/lib/webpack/render-html-plugin.js index 55d365e87..1e2828e0b 100644 --- a/packages/cli/lib/lib/webpack/render-html-plugin.js +++ b/packages/cli/lib/lib/webpack/render-html-plugin.js @@ -55,7 +55,7 @@ module.exports = async function (config) { writeFileSync(template, content); } - const htmlWebpackConfig = (values) => { + const htmlWebpackConfig = values => { const { url, title, ...routeData } = values; // Do not create a folder if the url is for a specific file. const filename = url.endsWith('.html') @@ -107,7 +107,8 @@ module.exports = async function (config) { let pages = [{ url: '/' }]; - if (config.prerenderUrls) { + // eslint-disable-next-line no-constant-condition + if (config.prerenderUrls && !config['experimental-fast-rendering']) { if (existsSync(resolve(cwd, config.prerenderUrls))) { try { let result = require(resolve(cwd, config.prerenderUrls)); @@ -151,14 +152,14 @@ module.exports = async function (config) { * And we dont have to cache every single html file. * Go easy on network usage of clients. */ - !pages.find((page) => page.url === PREACT_FALLBACK_URL) && + !pages.find(page => page.url === PREACT_FALLBACK_URL) && pages.push({ url: PREACT_FALLBACK_URL }); const resultPages = pages .map(htmlWebpackConfig) - .map((conf) => new HtmlWebpackPlugin(conf)) + .map(conf => new HtmlWebpackPlugin(conf)) .concat([new HtmlWebpackExcludeAssetsPlugin()]) - .concat([...pages.map((page) => new PrerenderDataExtractPlugin(page))]); + .concat([...pages.map(page => new PrerenderDataExtractPlugin(page))]); return resultPages; }; @@ -171,7 +172,7 @@ class PrerenderDataExtractPlugin { this.data_ = JSON.stringify(cliData.preRenderData || {}); } apply(compiler) { - compiler.hooks.emit.tap('PrerenderDataExtractPlugin', (compilation) => { + compiler.hooks.emit.tap('PrerenderDataExtractPlugin', compilation => { if (this.location_ === `${PREACT_FALLBACK_URL}/`) { // We dont build prerender data for `200.html`. It can re-use the one for homepage. return; diff --git a/packages/cli/lib/resources/body-end-modern.ejs b/packages/cli/lib/resources/body-end-modern.ejs new file mode 100644 index 000000000..3ae32ece3 --- /dev/null +++ b/packages/cli/lib/resources/body-end-modern.ejs @@ -0,0 +1,13 @@ +<%- options.ssr() %> +<% /* Fix for safari < 11 nomodule bug. TODO: Do the following only for safari. */ %> + + +<% + /*Fetch and Promise polyfills are not needed for browsers that support type=module + Please re-evaluate below line if adding more polyfills.*/ +%> + + + diff --git a/packages/cli/lib/resources/head-end-modern.ejs b/packages/cli/lib/resources/head-end-modern.ejs new file mode 100644 index 000000000..6736b4df4 --- /dev/null +++ b/packages/cli/lib/resources/head-end-modern.ejs @@ -0,0 +1,5 @@ + +<% if (options.manifest.theme_color) { %> + +<% } %> + diff --git a/packages/cli/package.json b/packages/cli/package.json index 7a5b916d2..24cc8ef93 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -77,6 +77,7 @@ "@babel/plugin-transform-react-jsx": "^7.9.0", "@babel/preset-env": "^7.9.0", "@babel/preset-typescript": "^7.9.0", + "@kristoferbaxter/async": "^1.0.0", "@preact/async-loader": "^3.0.1", "@prefresh/webpack": "^1.0.2", "autoprefixer": "^9.6.0", @@ -100,6 +101,7 @@ "get-port": "^5.0.0", "gittar": "^0.1.0", "glob": "^7.1.4", + "html-minifier": "^4.0.0", "html-webpack-exclude-assets-plugin": "0.0.7", "html-webpack-plugin": "^3.2.0", "ip": "^1.1.5", @@ -135,6 +137,7 @@ "webpack-bundle-analyzer": "^3.3.2", "webpack-dev-server": "^3.4.1", "webpack-fix-style-only-entries": "^0.5.1", + "webpack-manifest-plugin": "^2.2.0", "webpack-merge": "^4.1.0", "webpack-plugin-replace": "^1.2.0", "which": "^2.0.2", diff --git a/packages/cli/tests/build.test.js b/packages/cli/tests/build.test.js index 65b4ddb12..f10313a24 100644 --- a/packages/cli/tests/build.test.js +++ b/packages/cli/tests/build.test.js @@ -244,4 +244,25 @@ describe('preact build', () => { expect(mockExit).toHaveBeenCalledWith(1); mockExit.mockRestore(); }); + + it('should build with experimental fast rendering', async () => { + let dir = await subject('experimental-rendering'); + await build(dir); + const buildDir = join(dir, 'build'); + const prerenderedContentByHTMLWebpackPlugin = await readFile( + join(buildDir, 'index.html'), + 'utf-8' + ); + dir = await subject('experimental-rendering'); + await build(dir, { + 'experimental-fast-rendering': true, + }); + const prerenderedContentByExperimentalRendered = await readFile( + join(buildDir, 'index.html'), + 'utf-8' + ); + expect(prerenderedContentByHTMLWebpackPlugin).toEqual( + prerenderedContentByExperimentalRendered + ); + }); }); diff --git a/packages/cli/tests/subjects/experimental-rendering/index.js b/packages/cli/tests/subjects/experimental-rendering/index.js new file mode 100644 index 000000000..dcf3bb109 --- /dev/null +++ b/packages/cli/tests/subjects/experimental-rendering/index.js @@ -0,0 +1,3 @@ +import { h } from 'preact'; + +export default () =>