diff --git a/README.md b/README.md index 32bfe4a26..9179e3100 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - Fully **automatic code splitting** for routes - Transparently code-split any component with an [`async!`] prefix - Auto-generated [Service Workers] for offline caching powered by [sw-precache] -- [PRPL](https://developers.google.com/web/fundamentals/performance/prpl-pattern/) pattern support for efficient loading +- [PRPL] pattern support for efficient loading - Zero-configuration pre-rendering / server-side rendering hydration - Support for CSS Modules, LESS, Sass, Stylus; with Autoprefixer - Monitor your bundle/chunk sizes with built-in tracking @@ -106,6 +106,14 @@ npm run serve -- --server config # Copy your static files to a server! ``` +### Pre-rendering + +Preact CLI in order to follow [PRPL] pattern renders initial route (`/`) into generated static `index.html` - this ensures that users get to see your page before any JavaScript is run, and thus providing users with slow devices or poor connection your website's content much faster. + +Preact CLI does this by rendering your app inside node - this means that we don't have access to DOM or other global variables available in browsers, similar how it would be in server-side rendering scenarios. In case you need to rely on browser APIs you could: +- drop out of prerendering by passing `--no-prerender` flag to `preact build`, +- write your code in a way that supports server-side rendering by wrapping code that requires browser's APIs in conditional statements `if (typeof window !== "undefined") { ... }` ensuring that on server those lines of code are never reached. Alternatively you could use a helper library like [window-or-global](https://www.npmjs.com/package/window-or-global). + ### Custom Configuration #### Browserslist @@ -189,3 +197,4 @@ preact watch --template src/template.html [```.babelrc```]: https://babeljs.io/docs/usage/babelrc/ [Preact CLI preset]: https://github.com/developit/preact-cli/blob/master/src/lib/babel-config.js [WebpackConfigHelpers]: docs/webpack-helpers.md +[PRPL]: https://developers.google.com/web/fundamentals/performance/prpl-pattern/ diff --git a/package.json b/package.json index bbc6e15d5..7d808de06 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,8 @@ "rimraf": "^2.6.1", "script-ext-html-webpack-plugin": "^1.8.0", "simplehttp2server": "^2.0.0", + "source-map": "^0.5.6", + "stack-trace": "0.0.10", "sw-precache-webpack-plugin": "^0.11.2", "tmp": "0.0.31", "unfetch": "^3.0.0", diff --git a/src/lib/webpack/prerender.js b/src/lib/webpack/prerender.js index fb8c05534..9b36c6c13 100644 --- a/src/lib/webpack/prerender.js +++ b/src/lib/webpack/prerender.js @@ -1,27 +1,83 @@ import { resolve } from 'path'; +import fs from 'fs'; +import stackTrace from 'stack-trace'; +import { SourceMapConsumer } from 'source-map'; +import chalk from 'chalk'; -export default function prerender(outputDir, params) { +export default function prerender(env, params) { params = params || {}; - let entry = resolve(outputDir, './ssr-build/ssr-bundle.js'), + let entry = resolve(env.dest, './ssr-build/ssr-bundle.js'), url = params.url || '/'; global.location = { href:url, pathname:url }; global.history = {}; - let m = require(entry), + try { + let m = require(entry), app = m && m.default || m; - if (typeof app!=='function') { - // eslint-disable-next-line no-console - console.warn('Entry does not export a Component function/class, aborting prerendering.'); - return ''; - } + if (typeof app!=='function') { + // eslint-disable-next-line no-console + console.warn('Entry does not export a Component function/class, aborting prerendering.'); + return ''; + } - let preact = require('preact'), - renderToString = require('preact-render-to-string'); + let preact = require('preact'), + renderToString = require('preact-render-to-string'); - let html = renderToString(preact.h(app, { url })); + return renderToString(preact.h(app, { url })); + } catch (err) { + let stack = stackTrace.parse(err).filter(s => s.getFileName() === entry)[0]; + if (!stack) { + throw err; + } - return html; + handlePrerenderError(err, env, stack, entry); + } } + +const handlePrerenderError = (err, env, stack, entry) => { + let errorMessage = err.toString(); + let isReferenceError = errorMessage.startsWith('ReferenceError'); + let sourceMapContent = JSON.parse(fs.readFileSync(`${entry}.map`)); + let sourceMapConsumer = new SourceMapConsumer(sourceMapContent); + let position = sourceMapConsumer.originalPositionFor({ + line: stack.getLineNumber(), + column: stack.getColumnNumber() + }); + + position.source = position.source.replace('webpack://', '.'); + + let sourcePath = resolve(env.src, position.source); + let sourceLines = fs.readFileSync(sourcePath, 'utf-8').split('\n'); + let methodName = stack.getMethodName(); + let sourceCodeHighlight = ''; + + for (var i = -4; i <= 4; i++) { + let color = i === 0 ? chalk.red : chalk.yellow; + let line = position.line + i; + let sourceLine = sourceLines[line - 1]; + sourceCodeHighlight += sourceLine ? `${color(sourceLine)}\n` : ''; + } + + process.stderr.write('\n'); + process.stderr.write(chalk.red(`${errorMessage}\n`)); + process.stderr.write(`method: ${methodName}\n`); + process.stderr.write(`at: ${sourcePath}:${position.line}:${position.column}\n`); + process.stderr.write('\n'); + process.stderr.write('Source code:\n\n'); + process.stderr.write(sourceCodeHighlight); + process.stderr.write('\n'); + process.stderr.write(`This ${isReferenceError ? 'is most likely' : 'could be'} caused by using DOM or Web APIs.\n`); + process.stderr.write(`Pre-render runs in node and has no access to globals available in browsers.\n\n`); + process.stderr.write(`Consider wrapping code producing error in: 'if (typeof window !== "undefined") { ... }'\n`); + + if (methodName === 'componentWillMount') { + process.stderr.write(`or place logic in 'componentDidMount' method.\n`); + } + process.stderr.write('\n'); + process.stderr.write(`Alternatively use 'preact build --no-prerender' to disable prerendering.\n\n`); + process.stderr.write('See https://github.com/developit/preact-cli#pre-rendering for further information.'); + process.exit(1); +}; diff --git a/src/lib/webpack/webpack-base-config.js b/src/lib/webpack/webpack-base-config.js index ee62a33f3..b4898d93a 100644 --- a/src/lib/webpack/webpack-base-config.js +++ b/src/lib/webpack/webpack-base-config.js @@ -46,6 +46,7 @@ export default (env) => { env.src = '.'; } + env.dest = resolve(cwd, env.dest || 'build'); env.manifest = readJson(src('manifest.json')) || {}; env.pkg = readJson(resolve(cwd, 'package.json')) || {}; @@ -288,44 +289,6 @@ const production = () => addPlugins([ value: s => `return;${ Array(s.length-7).join(' ') }(` }] }), - - new webpack.optimize.UglifyJsPlugin({ - output: { - comments: false - }, - mangle: true, - sourceMap: true, - compress: { - properties: true, - keep_fargs: false, - pure_getters: true, - collapse_vars: true, - warnings: false, - screw_ie8: true, - sequences: true, - dead_code: true, - drop_debugger: true, - comparisons: true, - conditionals: true, - evaluate: true, - booleans: true, - loops: true, - unused: true, - hoist_funs: true, - if_return: true, - join_vars: true, - cascade: true, - drop_console: false, - pure_funcs: [ - 'classCallCheck', - '_classCallCheck', - '_possibleConstructorReturn', - 'Object.freeze', - 'invariant', - 'warning' - ] - } - }), ]); export function helpers(env) { diff --git a/src/lib/webpack/webpack-client-config.js b/src/lib/webpack/webpack-client-config.js index 48ac4ca9b..f66fc623b 100644 --- a/src/lib/webpack/webpack-client-config.js +++ b/src/lib/webpack/webpack-client-config.js @@ -21,8 +21,8 @@ import baseConfig, { exists, readJson, helpers } from './webpack-base-config'; import prerender from './prerender'; export default env => { - let { isProd, cwd, src } = helpers(env); - let outputDir = resolve(cwd, env.dest || 'build'); + let { isProd, src } = helpers(env); + return createConfig.vanilla([ baseConfig(env), entryPoint({ @@ -30,7 +30,7 @@ export default env => { 'polyfills': resolve(__dirname, './polyfills'), }), setOutput({ - path: outputDir, + path: env.dest, publicPath: '/', filename: '[name].js', chunkFilename: '[name].chunk.[chunkhash:5].js', @@ -94,7 +94,7 @@ export default env => { new PushManifestPlugin() ]), - htmlPlugin(env, outputDir), + htmlPlugin(env, src('.')), isProd ? production(env) : development(env), @@ -150,6 +150,43 @@ const development = config => { }; const production = config => addPlugins([ + new webpack.optimize.UglifyJsPlugin({ + output: { + comments: false + }, + mangle: true, + sourceMap: true, + compress: { + properties: true, + keep_fargs: false, + pure_getters: true, + collapse_vars: true, + warnings: false, + screw_ie8: true, + sequences: true, + dead_code: true, + drop_debugger: true, + comparisons: true, + conditionals: true, + evaluate: true, + booleans: true, + loops: true, + unused: true, + hoist_funs: true, + if_return: true, + join_vars: true, + cascade: true, + drop_console: false, + pure_funcs: [ + 'classCallCheck', + '_classCallCheck', + '_possibleConstructorReturn', + 'Object.freeze', + 'invariant', + 'warning' + ] + } + }), new SWPrecacheWebpackPlugin({ filename: 'sw.js', navigateFallback: 'index.html', @@ -164,9 +201,9 @@ const production = config => addPlugins([ }) ]); -const htmlPlugin = (config, outputDir) => { +const htmlPlugin = (config, src) => { const htmlWebpackConfig = ({ url, title }) => ({ - filename: resolve(outputDir, url.substring(1), 'index.html'), + filename: resolve(config.dest, url.substring(1), 'index.html'), template: `!!ejs-loader!${config.template || resolve(__dirname, '../../resources/template.html')}`, minify: config.production && { collapseWhitespace: true, @@ -184,7 +221,7 @@ const htmlPlugin = (config, outputDir) => { excludeAssets: [/(bundle|polyfills)(\..*)?\.js$/], config, ssr(params) { - return config.prerender ? prerender(outputDir, { ...params, url }) : ''; + return config.prerender ? prerender({ dest: config.dest, src }, { ...params, url }) : ''; } }); const pages = readJson(resolve(config.cwd, config.prerenderUrls || '')) || [{ url: "/" }]; diff --git a/src/lib/webpack/webpack-server-config.js b/src/lib/webpack/webpack-server-config.js index 782e27602..1cf751d78 100644 --- a/src/lib/webpack/webpack-server-config.js +++ b/src/lib/webpack/webpack-server-config.js @@ -5,15 +5,14 @@ import { customConfig } from '@webpack-blocks/webpack2'; import { resolve } from 'path'; -import baseConfig, { helpers } from './webpack-base-config'; +import baseConfig from './webpack-base-config'; export default (env) => { - let { cwd } = helpers(env); return createConfig.vanilla([ baseConfig(env), entryPoint(resolve(env.cwd, env.src || 'src', 'index.js')), setOutput({ - path: resolve(cwd, env.dest || 'build', 'ssr-build'), + path: resolve(env.dest, 'ssr-build'), publicPath: '/', filename: 'ssr-bundle.js', chunkFilename: '[name].chunk.[chunkhash:5].js', diff --git a/tests/build.snapshot.js b/tests/build.snapshot.js index 8e17ba520..91d56c2e5 100644 --- a/tests/build.snapshot.js +++ b/tests/build.snapshot.js @@ -40,8 +40,8 @@ export default { 'style.css': { size: 131 }, 'style.css.map': { size: 359 }, 'ssr-build': { - 'ssr-bundle.js': { size: 9450 }, - 'ssr-bundle.js.map': { size: 42461 }, + 'ssr-bundle.js': { size: 16245 }, + 'ssr-bundle.js.map': { size: 31821 }, 'style.css': { size: 130 }, 'style.css.map': { size: 360 }, } @@ -55,8 +55,8 @@ export default { 'style.css.map': { size: 621 }, 'manifest.json': { size: 290 }, 'ssr-build': { - 'ssr-bundle.js': { size: 10100 }, - 'ssr-bundle.js.map': { size: 46466 }, + 'ssr-bundle.js': { size: 18205 }, + 'ssr-bundle.js.map': { size: 33478 }, 'style.css': { size: 296 }, 'style.css.map': { size: 621 }, } @@ -74,8 +74,8 @@ export default { 'style.css': { size: 1065 }, 'style.css.map': { size: 2246 }, 'ssr-build': { - 'ssr-bundle.js': { size: 18960 }, - 'ssr-bundle.js.map': { size: 97403 }, + 'ssr-bundle.js': { size: 39459 }, + 'ssr-bundle.js.map': { size: 65629 }, 'style.css': { size: 1065 }, 'style.css.map': { size: 2250 }, } @@ -93,8 +93,8 @@ export default { 'style.css': { size: 1065 }, 'style.css.map': { size: 2345 }, 'ssr-build': { - 'ssr-bundle.js': { size: 19820 }, - 'ssr-bundle.js.map': { size: 101502 }, + 'ssr-bundle.js': { size: 41715 }, + 'ssr-bundle.js.map': { size: 66661 }, 'style.css': { size: 1065 }, 'style.css.map': { size: 2345 }, }