diff --git a/app/components/LoadingIndicator/styles.css b/app/components/LoadingIndicator/styles.css index da15179f24..f5a5d0dccb 100644 --- a/app/components/LoadingIndicator/styles.css +++ b/app/components/LoadingIndicator/styles.css @@ -128,14 +128,14 @@ opacity: 0; } - 40% { - opacity: 1; - } - 39% { opacity: 0; } + 40% { + opacity: 1; + } + 100% { opacity: 0; } diff --git a/internals/config.js b/internals/config.js new file mode 100644 index 0000000000..9004a6940f --- /dev/null +++ b/internals/config.js @@ -0,0 +1,56 @@ +const resolve = require('path').resolve; +const pullAll = require('lodash/pullAll'); +const uniq = require('lodash/uniq'); + +const ReactBoilerplate = { + // This refers to the react-boilerplate version this project is based on. + version: '3.0.0', + + /** + * The DLL Plugin provides a dramatic speed increase to webpack build and hot module reloading + * by caching the module metadata for all of our npm dependencies. We enable it by default + * in development. + * + * + * To disable the DLL Plugin, set this value to false. + */ + dllPlugin: { + defaults: { + /** + * we need to exclude dependencies which are not intended for the browser + * by listing them here. + */ + exclude: [ + 'chalk', + 'compression', + 'cross-env', + 'express', + 'ip', + 'minimist', + 'sanitize.css', + ], + + /** + * Specify any additional dependencies here. We include core-js and lodash + * since a lot of our dependencies depend on them and they get picked up by webpack. + */ + include: ['core-js', 'eventsource-polyfill', 'babel-polyfill', 'lodash'], + + // The path where the DLL manifest and bundle will get built + path: resolve('../node_modules/react-boilerplate-dlls'), + }, + + entry(pkg) { + const dependencyNames = Object.keys(pkg.dependencies); + const exclude = pkg.dllPlugin.exclude || ReactBoilerplate.dllPlugin.defaults.exclude; + const include = pkg.dllPlugin.include || ReactBoilerplate.dllPlugin.defaults.include; + const includeDependencies = uniq(dependencyNames.concat(include)); + + return { + reactBoilerplateDeps: pullAll(includeDependencies, exclude), + }; + }, + }, +}; + +module.exports = ReactBoilerplate; diff --git a/internals/scripts/dependencies.js b/internals/scripts/dependencies.js new file mode 100644 index 0000000000..0cdb064890 --- /dev/null +++ b/internals/scripts/dependencies.js @@ -0,0 +1,49 @@ +/*eslint-disable*/ + +// No need to build the DLL in production +if (process.env.NODE_ENV === 'production') { + process.exit(0) +} + +require('shelljs/global') + +const path = require('path') +const fs = require('fs') +const exists = fs.existsSync +const writeFile = fs.writeFileSync + +const defaults = require('lodash/defaultsDeep') +const pkg = require(path.join(process.cwd(), 'package.json')) +const config = require('../config') +const dllConfig = defaults(pkg.dllPlugin, config.dllPlugin.defaults) +const outputPath = path.join(process.cwd(), dllConfig.path) +const dllManifestPath = path.join(outputPath, 'package.json') + +/** + * I use node_modules/react-boilerplate-dlls by default just because + * it isn't going to be version controlled and babel wont try to parse it. + */ +mkdir('-p', outputPath) + +echo('Building the Webpack DLL...') + +/** + * Create a manifest so npm install doesnt warn us + */ +if (!exists(dllManifestPath)) { + writeFile( + dllManifestPath, + JSON.stringify(defaults({ + name: 'react-boilerplate-dlls', + private: true, + author: pkg.author, + repository: pkg.repository, + version: pkg.version + }), null, 2), + + 'utf8' + ) +} + +// the BUILDING_DLL env var is set to avoid confusing the development environment +exec('cross-env BUILDING_DLL=true webpack --display-chunks --color --config internals/webpack/webpack.dll.babel.js') diff --git a/internals/webpack/webpack.base.babel.js b/internals/webpack/webpack.base.babel.js index 854b39397d..1988e4f5d3 100644 --- a/internals/webpack/webpack.base.babel.js +++ b/internals/webpack/webpack.base.babel.js @@ -55,7 +55,6 @@ module.exports = (options) => ({ }], }, plugins: options.plugins.concat([ - new webpack.optimize.CommonsChunkPlugin('common'), new webpack.ProvidePlugin({ // make fetch available fetch: 'exports?self.fetch!whatwg-fetch', diff --git a/internals/webpack/webpack.dev.babel.js b/internals/webpack/webpack.dev.babel.js index 2c88ef18f9..b712284415 100644 --- a/internals/webpack/webpack.dev.babel.js +++ b/internals/webpack/webpack.dev.babel.js @@ -3,14 +3,28 @@ */ const path = require('path'); +const fs = require('fs'); const webpack = require('webpack'); const HtmlWebpackPlugin = require('html-webpack-plugin'); +const logger = require('../../server/logger'); +const cheerio = require('cheerio'); +const pkg = require(path.resolve(process.cwd(), 'package.json')); +const dllPlugin = pkg.dllPlugin; // PostCSS plugins const cssnext = require('postcss-cssnext'); const postcssFocus = require('postcss-focus'); const postcssReporter = require('postcss-reporter'); +const plugins = [ + new webpack.HotModuleReplacementPlugin(), // Tell webpack we want hot reloading + new webpack.NoErrorsPlugin(), + new HtmlWebpackPlugin({ + inject: true, // Inject all files that are generated by webpack, e.g. bundle.js + templateContent: templateContent(), // eslint-disable-line no-use-before-define + }), +]; + module.exports = require('./webpack.base.babel')({ // Add hot reloading in development entry: [ @@ -25,6 +39,9 @@ module.exports = require('./webpack.base.babel')({ chunkFilename: '[name].chunk.js', }, + // Add development plugins + plugins: dependencyHandlers().concat(plugins), // eslint-disable-line no-use-before-define + // Load the CSS in a style tag in development cssLoaders: 'style-loader!css-loader?localIdentName=[local]__[path][name]__[hash:base64:5]&modules&importLoaders=1&sourceMap!postcss-loader', @@ -39,16 +56,6 @@ module.exports = require('./webpack.base.babel')({ }), ], - // Add hot reloading - plugins: [ - new webpack.HotModuleReplacementPlugin(), // Tell webpack we want hot reloading - new webpack.NoErrorsPlugin(), - new HtmlWebpackPlugin({ - template: 'app/index.html', - inject: true, // Inject all files that are generated by webpack, e.g. bundle.js - }), - ], - // Tell babel that we want to hot-reload babelQuery: { presets: ['react-hmre'], @@ -57,3 +64,93 @@ module.exports = require('./webpack.base.babel')({ // Emit a source map for easier debugging devtool: 'cheap-module-eval-source-map', }); + +/** + * Select which plugins to use to optimize the bundle's handling of + * third party dependencies. + * + * If there is a dllPlugin key on the project's package.json, the + * Webpack DLL Plugin will be used. Otherwise the CommonsChunkPlugin + * will be used. + * + */ +function dependencyHandlers() { + // Don't do anything during the DLL Build step + if (process.env.BUILDING_DLL) { return []; } + + // If the package.json does not have a dllPlugin property, use the CommonsChunkPlugin + if (!dllPlugin) { + return [ + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor', + children: true, + minChunks: 2, + async: true, + }), + ]; + } + + const dllPath = path.resolve(process.cwd(), dllPlugin.path || 'node_modules/react-boilerplate-dlls'); + + /** + * If DLLs aren't explicitly defined, we assume all production dependencies listed in package.json + * Reminder: You need to exclude any server side dependencies by listing them in dllConfig.exclude + * + * @see https://github.com/mxstbr/react-boilerplate/tree/master/docs/general/webpack.md + */ + if (!dllPlugin.dlls) { + const manifestPath = path.resolve(dllPath, 'reactBoilerplateDeps.json'); + + if (!fs.existsSync(manifestPath)) { + logger.error('The DLL manifest is missing. Please run `npm run build:dll`'); + process.exit(0); + } + + return [ + new webpack.DllReferencePlugin({ + context: process.cwd(), + manifest: require(manifestPath), // eslint-disable-line global-require + }), + ]; + } + + // If DLLs are explicitly defined, we automatically create a DLLReferencePlugin for each of them. + const dllManifests = Object.keys(dllPlugin.dlls).map((name) => path.join(dllPath, `/${name}.json`)); + + return dllManifests.map((manifestPath) => { + if (!fs.existsSync(path)) { + if (!fs.existsSync(manifestPath)) { + logger.error(`The following Webpack DLL manifest is missing: ${path.basename(manifestPath)}`); + logger.error(`Expected to find it in ${dllPath}`); + logger.error('Please run: npm run build:dll'); + + process.exit(0); + } + } + + return new webpack.DllReferencePlugin({ + context: process.cwd(), + manifest: require(manifestPath), // eslint-disable-line global-require + }); + }); +} + +/** + * We dynamically generate the HTML content in development so that the different + * DLL Javascript files are loaded in script tags and available to our application. + */ +function templateContent() { + const html = fs.readFileSync( + path.resolve(process.cwd(), 'app/index.html') + ).toString(); + + if (!dllPlugin) { return html; } + + const doc = cheerio(html); + const body = doc.find('body'); + const dllNames = !dllPlugin.dlls ? ['reactBoilerplateDeps'] : Object.keys(dllPlugin.dlls); + + dllNames.forEach(dllName => body.append(``)); + + return doc.toString(); +} diff --git a/internals/webpack/webpack.dll.babel.js b/internals/webpack/webpack.dll.babel.js new file mode 100644 index 0000000000..c2bd0cd242 --- /dev/null +++ b/internals/webpack/webpack.dll.babel.js @@ -0,0 +1,34 @@ +/** + * WEBPACK DLL GENERATOR + * + * This profile is used to cache webpack's module + * contexts for external library and framework type + * dependencies which will usually not change often enough + * to warrant building them from scratch every time we use + * the webpack process. + */ + +const { join } = require('path'); +const defaults = require('lodash/defaultsDeep'); +const webpack = require('webpack'); +const pkg = require(join(process.cwd(), 'package.json')); +const dllPlugin = require('../config').dllPlugin; + +if (!pkg.dllPlugin) { process.exit(0); } + +const dllConfig = defaults(pkg.dllPlugin, dllPlugin.defaults); +const outputPath = join(process.cwd(), dllConfig.path); + +module.exports = { + context: process.cwd(), + entry: dllConfig.dlls ? dllConfig.dlls : dllPlugin.entry(pkg), + devtool: 'eval', + output: { + filename: '[name].dll.js', + path: outputPath, + library: '[name]', + }, + plugins: [ + new webpack.DllPlugin({ name: '[name]', path: join(outputPath, '[name].json') }), // eslint-disable-line no-new + ], +}; diff --git a/internals/webpack/webpack.prod.babel.js b/internals/webpack/webpack.prod.babel.js index 32b9c15024..9485f3207e 100644 --- a/internals/webpack/webpack.prod.babel.js +++ b/internals/webpack/webpack.prod.babel.js @@ -40,6 +40,12 @@ module.exports = require('./webpack.base.babel')({ }), ], plugins: [ + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor', + children: true, + minChunks: 2, + async: true, + }), // OccurrenceOrderPlugin is needed for long-term caching to work properly. // See http://mxs.is/googmv diff --git a/package.json b/package.json index d309baf876..9739102cee 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,14 @@ "author": "Max Stoiber", "license": "MIT", "scripts": { + "analyze": "node ./internals/scripts/analyze.js", "npmcheckversion": "node ./internals/scripts/npmcheckversion.js", "preinstall": "npm run npmcheckversion", + "postinstall": "npm run build:dll", "prebuild": "npm run test && npm run build:clean", - "build:clean": "rimraf ./build/*", "build": "cross-env NODE_ENV=production webpack --config internals/webpack/webpack.prod.babel.js --color -p", - "analyze": "node ./internals/scripts/analyze.js", + "build:clean": "rimraf ./build/*", + "build:dll": "node ./internals/scripts/dependencies.js", "start": "cross-env NODE_ENV=development node server", "start:tunnel": "cross-env NODE_ENV=development ENABLE_TUNNEL=true node server", "start:production": "npm run build && npm run start:prod", @@ -25,6 +27,7 @@ "pagespeed": "node ./internals/scripts/pagespeed.js", "presetup": "npm i chalk", "setup": "node ./internals/scripts/setup.js", + "postsetup": "npm run build:dll", "clean": "shjs ./internals/scripts/clean.js", "generate": "plop --plopfile internals/generators/index.js", "lint": "npm run lint:js && npm run lint:css", @@ -99,15 +102,33 @@ "indentation": 2 } }, + "dllPlugin": { + "path": "node_modules/react-boilerplate-dlls", + "exclude": [ + "chalk", + "compression", + "cross-env", + "express", + "ip", + "minimist", + "sanitize.css" + ], + "include": [ + "core-js", + "lodash", + "eventsource-polyfill" + ] + }, "dependencies": { "babel-polyfill": "^6.7.4", "chalk": "^1.1.3", "compression": "^1.6.1", "express": "^4.13.4", - "file-loader": "^0.8.5", "fontfaceobserver": "^1.7.1", "history": "^2.1.0", "immutable": "^3.8.1", + "ip": "^1.1.2", + "minimist": "^1.2.0", "react": "^15.0.1", "react-dom": "^15.0.1", "react-redux": "^4.4.5", @@ -172,6 +193,7 @@ "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^1.7.0", "lint-staged": "^1.0.0", + "lodash": "^4.13.1", "minimist": "^1.2.0", "mocha": "^2.4.5", "ngrok": "2.1.8", diff --git a/server/index.js b/server/index.js index 6f48663b1d..917e777e55 100644 --- a/server/index.js +++ b/server/index.js @@ -2,27 +2,24 @@ const express = require('express'); const logger = require('./logger'); -const ngrok = require('ngrok'); -const frontend = require('./middlewares/frontendMiddleware'); +const argv = require('minimist')(process.argv.slice(2)); +const setup = require('./middlewares/frontendMiddleware'); const isDev = process.env.NODE_ENV !== 'production'; -const useTunnel = isDev && process.env.ENABLE_TUNNEL; - -const app = express(); +const ngrok = (isDev && process.env.ENABLE_TUNNEL) || argv.tunnel ? require('ngrok') : false; +const resolve = require('path').resolve; // If you need a backend, e.g. an API, add your custom backend-specific middleware here // app.use('/api', myApi); -// Initialize frontend middleware that will serve your JS app -const webpackConfig = isDev - ? require('../internals/webpack/webpack.dev.babel') - : require('../internals/webpack/webpack.prod.babel'); - -app.use(frontend(webpackConfig)); +// In production we need to pass these values in instead of relying on webpack +const app = setup(express(), { + outputPath: resolve(process.cwd(), 'build'), + publicPath: '/', +}); // get the intended port number, use port 3000 if not provided -const intendedPort = process.argv[3] || 3000; -const port = process.env.PORT || intendedPort; +const port = argv.port || process.env.PORT || 3000; // Start your app. app.listen(port, (err) => { @@ -31,7 +28,7 @@ app.listen(port, (err) => { } // Connect to ngrok in dev mode - if (isDev && useTunnel) { + if (ngrok) { ngrok.connect(port, (innerErr, url) => { if (innerErr) { return logger.error(innerErr); diff --git a/server/middlewares/frontendMiddleware.js b/server/middlewares/frontendMiddleware.js index 324a7693e0..7436a67589 100644 --- a/server/middlewares/frontendMiddleware.js +++ b/server/middlewares/frontendMiddleware.js @@ -1,16 +1,18 @@ +/* eslint-disable global-require */ const express = require('express'); const path = require('path'); const compression = require('compression'); -const webpackDevMiddleware = require('webpack-dev-middleware'); -const webpackHotMiddleware = require('webpack-hot-middleware'); -const webpack = require('webpack'); +const pkg = require(path.resolve(process.cwd(), 'package.json')); // Dev middleware -const addDevMiddlewares = (app, options) => { - const compiler = webpack(options); +const addDevMiddlewares = (app, webpackConfig) => { + const webpack = require('webpack'); + const webpackDevMiddleware = require('webpack-dev-middleware'); + const webpackHotMiddleware = require('webpack-hot-middleware'); + const compiler = webpack(webpackConfig); const middleware = webpackDevMiddleware(compiler, { noInfo: true, - publicPath: options.output.publicPath, + publicPath: webpackConfig.output.publicPath, silent: true, stats: 'errors-only', }); @@ -22,35 +24,49 @@ const addDevMiddlewares = (app, options) => { // artifacts, we use it instead const fs = middleware.fileSystem; + if (pkg.dllPlugin) { + app.get(/\.dll\.js$/, (req, res) => { + const filename = req.path.replace(/^\//, ''); + res.sendFile(path.join(process.cwd(), pkg.dllPlugin.path, filename)); + }); + } + app.get('*', (req, res) => { - const file = fs.readFileSync(path.join(compiler.outputPath, 'index.html')); - res.send(file.toString()); + fs.readFile(path.join(compiler.outputPath, 'index.html'), (file, error) => { + if (error) { + res.sendStatus(404); + } else { + res.send(file.toString()); + } + }); }); }; // Production middlewares const addProdMiddlewares = (app, options) => { + const publicPath = options.publicPath || '/'; + const outputPath = options.outputPath || path.resolve(process.cwd(), 'build'); + // compression middleware compresses your server responses which makes them // smaller (applies also to assets). You can read more about that technique // and other good practices on official Express.js docs http://mxs.is/googmy app.use(compression()); - app.use(options.output.publicPath, express.static(options.output.path)); + app.use(publicPath, express.static(outputPath)); - app.get('*', (req, res) => res.sendFile(path.join(options.output.path, 'index.html'))); + app.get('*', (req, res) => res.sendFile(path.resolve(outputPath, 'index.html'))); }; /** * Front-end middleware */ -module.exports = (options) => { +module.exports = (app, options) => { const isProd = process.env.NODE_ENV === 'production'; - const app = express(); - if (isProd) { addProdMiddlewares(app, options); } else { - addDevMiddlewares(app, options); + const webpackConfig = require('../../internals/webpack/webpack.dev.babel'); + addDevMiddlewares(app, webpackConfig); } return app;