diff --git a/packages/react-dev-utils/README.md b/packages/react-dev-utils/README.md index 166e1bfaea3..69836b75e95 100644 --- a/packages/react-dev-utils/README.md +++ b/packages/react-dev-utils/README.md @@ -312,3 +312,24 @@ module.exports = { // ... } ``` + +#### `webpackAutoDllCompiler` +Use this to automatically generate dll bundle based on the hash of yarn.lock (if exists), package.json, and a dll entry point defined in your paths. + +usage: +``` +webpackAutoDllCompiler({ + mainConfig: config, + dllConfig, // a function that returns dll webpack config + paths: { + // dll entry point, import all your dependencies here + dllSrc: './src/dll.js', + // dll cache path + dllPath: './node_modules/.cache/dll', + appPackageJson // path to your package json + yarnLockFile: // path to your yarn lock file + }, +}).then(config => { + webpack(config) // or do anything else +}) +``` \ No newline at end of file diff --git a/packages/react-dev-utils/package.json b/packages/react-dev-utils/package.json index 46f3a92033a..5a2a8d6a0bf 100644 --- a/packages/react-dev-utils/package.json +++ b/packages/react-dev-utils/package.json @@ -29,11 +29,13 @@ "openChrome.applescript", "printHostingInstructions.js", "WatchMissingNodeModulesPlugin.js", + "webpackAutoDllCompiler.js", "WebpackDevServerUtils.js", "webpackHotDevClient.js" ], "dependencies": { "@timer/detect-port": "1.1.3", + "add-asset-html-webpack-plugin": "^2.0.1", "address": "1.0.1", "anser": "1.3.0", "babel-code-frame": "6.22.0", @@ -41,6 +43,7 @@ "cross-spawn": "4.0.2", "escape-string-regexp": "1.0.5", "filesize": "3.3.0", + "fs-extra": "3.0.1", "gzip-size": "3.0.0", "html-entities": "1.2.1", "inquirer": "3.0.6", @@ -49,6 +52,7 @@ "shell-quote": "1.6.1", "sockjs-client": "1.1.4", "strip-ansi": "3.0.1", - "text-table": "0.2.0" + "text-table": "0.2.0", + "webpack": "2.5.1" } } diff --git a/packages/react-dev-utils/webpackAutoDllCompiler.js b/packages/react-dev-utils/webpackAutoDllCompiler.js new file mode 100644 index 00000000000..006809f72c1 --- /dev/null +++ b/packages/react-dev-utils/webpackAutoDllCompiler.js @@ -0,0 +1,165 @@ +'use strict'; +const fs = require('fs-extra'); +const path = require('path'); +const webpack = require('webpack'); +const crypto = require('crypto'); +const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin'); +const clearConsole = require('./clearConsole'); +const chalk = require('chalk'); +const environment = process.env.NODE_ENV; + +// inspired by https://github.com/erm0l0v/webpack-md5-hash/blob/da8efa2fc7fe5c373c95f9ba859dbe208a8b844b/plugin/webpack_md5_hash.js +class WebpackAdditionalSourceHashPlugin { + constructor({ additionalSourceHash }) { + this.additionalSourceHash = additionalSourceHash; + } + apply(compiler) { + compiler.plugin('compilation', compilation => { + compilation.plugin('chunk-hash', (chunk, chunkHash) => { + const oldHash = chunkHash.digest(); + chunkHash.digest = () => { + const hash = crypto.createHash('md5'); + hash.update(this.additionalSourceHash); + hash.update(oldHash); + return hash.digest('hex'); + }; + }); + }); + } +} + +module.exports = ({ dllConfig, paths }) => + mainConfig => new Promise(resolve => { + const dllHash = getDllHash(paths.dllSrc); + if (dllHash === false) { + // we cannot find dllSrc. + // continue without enabling dll feature. + return resolve(mainConfig); + } + + //start the procedure for building dll bundle + clearConsole(); + const dllPath = paths.dllPath; + const dllBundleFilePath = path.join(dllPath, dllHash + '.js'); + const dllManifestFilePath = path.join(dllPath, dllHash + '.json'); + const config = dllConfig(dllHash); + console.log('Checking if ' + dllHash + ' dll bundle exists'); + if (dllExists()) { + console.log(chalk.green('Dll bundle is up to date and safe to use!')); + // Just run the main compiler if dll bundler is up to date + return resolve(resolveConfig(mainConfig)); + } + console.log('Dll bundle needs to be compiled...'); + // Read dll path for stale files + fs.readdir(dllPath, (err, files) => { + cleanUpStaleFiles(files); + + console.log('Compiling dll bundle for faster rebuilds...'); + webpack(config).run((err, stats) => { + checkForErrors(err, stats); + + // When the process still run until here, there are no errors :) + console.log(chalk.green('Dll bundle compiled successfully!')); + resolve(resolveConfig(mainConfig)); // Let the main compiler do its job + }); + }); + + function dllExists() { + return fs.existsSync(dllManifestFilePath) && + fs.existsSync(dllBundleFilePath); + } + + function cleanUpStaleFiles(files) { + try { + // delete all stale dll bundle for this environment + files.filter(file => !file.indexOf(environment)).forEach(file => { + fs.unlinkSync(path.join(dllPath, file)); + }); + } catch (ignored) { + //ignored + } + } + + function resolveConfig(mainConfig) { + return Object.assign({}, mainConfig, { + entry: mainConfig.entry.filter(path => !config.entry.includes(path)), + plugins: mainConfig.plugins + .concat([ + new WebpackAdditionalSourceHashPlugin({ + additionalSourceHash: dllHash, + }), + new webpack.DllReferencePlugin({ + context: '.', + manifest: require(dllManifestFilePath), + }), + new AddAssetHtmlPlugin({ + outputPath: path.join('static', 'js'), + publicPath: mainConfig.output.publicPath + + path.join('static', 'js'), + filepath: require.resolve(dllBundleFilePath), + }), + ]) + .map(plugin => { + if (plugin.constructor.name === 'ManifestPlugin') { + plugin.opts.cache = { + 'dll.js': path.join('static', 'js', dllHash + '.js'), + 'dll.js.map': path.join('static', 'js', dllHash + '.js.map'), + }; + } + return plugin; + }), + }); + } + + function getDllHash(dllSrc) { + if (fs.existsSync(dllSrc)) { + const hash = crypto.createHash('md5'); + const input = fs.readFileSync(dllSrc); + const appPackageJson = fs.readFileSync(paths.appPackageJson); + + hash.update(input); + hash.update(appPackageJson); + + if (fs.existsSync(paths.yarnLockFile)) { + hash.update(fs.readFileSync(paths.yarnLockFile)); + } + + if (fs.existsSync(paths.packageLockFile)) { + hash.update(fs.readFileSync(paths.packageLockFile)); + } + + return [environment, hash.digest('hex').substring(0, 8)].join('.'); + } else { + return false; + } + } + }); + +function printErrors(summary, errors) { + console.log(chalk.red(summary)); + console.log(); + errors.forEach(err => { + console.log(err.message || err); + console.log(); + }); +} + +function checkForErrors(err, stats) { + if (err) { + printErrors('Failed to compile.', [err]); + process.exit(1); + } + + if (stats.compilation.errors.length) { + printErrors('Failed to compile.', stats.compilation.errors); + process.exit(1); + } + + if (process.env.CI && stats.compilation.warnings.length) { + printErrors( + 'Failed to compile. When process.env.CI = true, warnings are treated as failures. Most CI servers set this automatically.', + stats.compilation.warnings + ); + process.exit(1); + } +} diff --git a/packages/react-scripts/config/paths.js b/packages/react-scripts/config/paths.js index 42ec8374a15..5243789458e 100644 --- a/packages/react-scripts/config/paths.js +++ b/packages/react-scripts/config/paths.js @@ -19,6 +19,8 @@ const url = require('url'); const appDirectory = fs.realpathSync(process.cwd()); const resolveApp = relativePath => path.resolve(appDirectory, relativePath); +const dllPath = 'node_modules/.cache/dll'; + const envPublicUrl = process.env.PUBLIC_URL; function ensureSlash(path, needsSlash) { @@ -58,8 +60,11 @@ module.exports = { appPackageJson: resolveApp('package.json'), appSrc: resolveApp('src'), yarnLockFile: resolveApp('yarn.lock'), + packageLockFile: resolveApp('package-lock.json'), testsSetup: resolveApp('src/setupTests.js'), appNodeModules: resolveApp('node_modules'), + dllPath: resolveApp(dllPath), + dllSrc: resolveApp('src/index.dll.js'), publicUrl: getPublicUrl(resolveApp('package.json')), servedPath: getServedPath(resolveApp('package.json')), }; @@ -78,8 +83,11 @@ module.exports = { appPackageJson: resolveApp('package.json'), appSrc: resolveApp('src'), yarnLockFile: resolveApp('yarn.lock'), + packageLockFile: resolveApp('package-lock.json'), testsSetup: resolveApp('src/setupTests.js'), appNodeModules: resolveApp('node_modules'), + dllPath: resolveApp(dllPath), + dllSrc: resolveApp('src/index.dll.js'), publicUrl: getPublicUrl(resolveApp('package.json')), servedPath: getServedPath(resolveApp('package.json')), // These properties only exist before ejecting: @@ -107,8 +115,11 @@ if ( appPackageJson: resolveOwn('package.json'), appSrc: resolveOwn('template/src'), yarnLockFile: resolveOwn('template/yarn.lock'), + packageLockFile: resolveOwn('template/package-lock.json'), testsSetup: resolveOwn('template/src/setupTests.js'), appNodeModules: resolveOwn('node_modules'), + dllPath: resolveOwn(dllPath), + dllSrc: resolveOwn('template/src/index.dll.js'), publicUrl: getPublicUrl(resolveOwn('package.json')), servedPath: getServedPath(resolveOwn('package.json')), // These properties only exist before ejecting: diff --git a/packages/react-scripts/config/webpack.config.dll.js b/packages/react-scripts/config/webpack.config.dll.js new file mode 100644 index 00000000000..d434adc0029 --- /dev/null +++ b/packages/react-scripts/config/webpack.config.dll.js @@ -0,0 +1,86 @@ +'use strict'; +const webpack = require('webpack'); +const paths = require('./paths'); +const path = require('path'); +const getClientEnvironment = require('./env'); +const publicPath = paths.servedPath; +const publicUrl = publicPath.slice(0, -1); +const env = getClientEnvironment(publicUrl); +const isProduction = process.env.NODE_ENV === 'production'; + +module.exports = dllHash => { + const dllGlobalName = '[name]' + dllHash.replace(/\./g, ''); + return { + cache: true, + entry: [require.resolve('./polyfills'), paths.dllSrc], + devtool: 'source-map', + output: { + filename: dllHash + '.js', + path: paths.dllPath, + library: dllGlobalName, + }, + resolve: { + // This allows you to set a fallback for where Webpack should look for modules. + // We read `NODE_PATH` environment variable in `paths.js` and pass paths here. + // We placed these paths second because we want `node_modules` to "win" + // if there are any conflicts. This matches Node resolution mechanism. + // https://github.com/facebookincubator/create-react-app/issues/253 + modules: ['node_modules'].concat(paths.appNodeModules), + // These are the reasonable defaults supported by the Node ecosystem. + // We also include JSX as a common component filename extension to support + // some tools, although we do not recommend using it, see: + // https://github.com/facebookincubator/create-react-app/issues/290 + extensions: ['.js', '.json', '.jsx'], + alias: { + // Support React Native Web + // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/ + 'react-native': 'react-native-web', + }, + }, + // @remove-on-eject-begin + // Resolve loaders (webpack plugins for CSS, images, transpilation) from the + // directory of `react-scripts` itself rather than the project directory. + resolveLoader: { + modules: [ + paths.ownNodeModules, + // Lerna hoists everything, so we need to look in our app directory + paths.appNodeModules, + ], + }, + // @remove-on-eject-end + plugins: [ + new webpack.DllPlugin({ + // The path to the manifest file which maps between + // modules included in a bundle and the internal IDs + // within that bundle + path: path.join(paths.dllPath, dllHash + '.json'), + // The name of the global variable which the library's + // require function has been assigned to. This must match the + // output.library option above + name: dllGlobalName, + }), + isProduction ? new webpack.DefinePlugin(env.stringified) : null, + isProduction + ? new webpack.optimize.UglifyJsPlugin({ + compress: { + screw_ie8: true, // React doesn't support IE8 + warnings: false, + }, + mangle: { + screw_ie8: true, + }, + output: { + comments: false, + screw_ie8: true, + }, + sourceMap: true, + }) + : null, + ].filter(Boolean), + node: { + fs: 'empty', + net: 'empty', + tls: 'empty', + }, + }; +}; diff --git a/packages/react-scripts/package.json b/packages/react-scripts/package.json index e6879f756af..d07a1b67d10 100644 --- a/packages/react-scripts/package.json +++ b/packages/react-scripts/package.json @@ -48,6 +48,7 @@ "postcss-flexbugs-fixes": "3.0.0", "postcss-loader": "2.0.5", "promise": "7.1.1", + "promise-compose": "^1.1.2", "react-dev-utils": "^3.0.0", "react-error-overlay": "^1.0.7", "style-loader": "0.17.0", diff --git a/packages/react-scripts/scripts/build.js b/packages/react-scripts/scripts/build.js index a83d287ded4..fce7ee23d26 100644 --- a/packages/react-scripts/scripts/build.js +++ b/packages/react-scripts/scripts/build.js @@ -29,11 +29,14 @@ const chalk = require('chalk'); const fs = require('fs-extra'); const webpack = require('webpack'); const config = require('../config/webpack.config.prod'); +const dllConfig = require('../config/webpack.config.dll'); const paths = require('../config/paths'); const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); const printHostingInstructions = require('react-dev-utils/printHostingInstructions'); const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); +const webpackAutoDllCompiler = require('react-dev-utils/webpackAutoDllCompiler'); +const compose = require('promise-compose'); const measureFileSizesBeforeBuild = FileSizeReporter.measureFileSizesBeforeBuild; const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; @@ -44,9 +47,21 @@ if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { process.exit(1); } -// First, read the current file sizes in build directory. -// This lets us display how much they changed later. -measureFileSizesBeforeBuild(paths.appBuild) +// This is Promise based composer, it accepts functions that returns +// function that accepts webpack configuration object +const configComposer = compose( + // check dll for updates + webpackAutoDllCompiler({ + dllConfig, + paths, + }) +); + +configComposer(config).then(config => measureFileSizesBeforeBuild( + // First, read the current file sizes in build directory. + // This lets us display how much they changed later. + paths.appBuild +) .then(previousFileSizes => { // Remove all content but keep the directory so that // if you're in it, you don't end up in Trash @@ -54,7 +69,7 @@ measureFileSizesBeforeBuild(paths.appBuild) // Merge with the public folder copyPublicFolder(); // Start the webpack build - return build(previousFileSizes); + return build(previousFileSizes, config); }) .then( ({ stats, previousFileSizes, warnings }) => { @@ -96,10 +111,10 @@ measureFileSizesBeforeBuild(paths.appBuild) console.log((err.message || err) + '\n'); process.exit(1); } - ); + )); // Create the production build and print the deployment instructions. -function build(previousFileSizes) { +function build(previousFileSizes, config) { console.log('Creating an optimized production build...'); let compiler = webpack(config); diff --git a/packages/react-scripts/scripts/start.js b/packages/react-scripts/scripts/start.js index b86943b4d9d..316e3a81946 100644 --- a/packages/react-scripts/scripts/start.js +++ b/packages/react-scripts/scripts/start.js @@ -27,6 +27,7 @@ require('../config/env'); const fs = require('fs'); const chalk = require('chalk'); const webpack = require('webpack'); +const compose = require('promise-compose'); const WebpackDevServer = require('webpack-dev-server'); const clearConsole = require('react-dev-utils/clearConsole'); const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); @@ -37,8 +38,10 @@ const { prepareUrls, } = require('react-dev-utils/WebpackDevServerUtils'); const openBrowser = require('react-dev-utils/openBrowser'); +const webpackAutoDllCompiler = require('react-dev-utils/webpackAutoDllCompiler'); const paths = require('../config/paths'); const config = require('../config/webpack.config.dev'); +const dllConfig = require('../config/webpack.config.dll'); const createDevServerConfig = require('../config/webpackDevServer.config'); const useYarn = fs.existsSync(paths.yarnLockFile); @@ -49,54 +52,63 @@ if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { process.exit(1); } -// Tools like Cloud9 rely on this. -const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; -const HOST = process.env.HOST || '0.0.0.0'; +const configComposer = compose( + webpackAutoDllCompiler({ + dllConfig, + paths, + }) +); -// We attempt to use the default port but if it is busy, we offer the user to -// run on a different port. `detect()` Promise resolves to the next free port. -choosePort(HOST, DEFAULT_PORT) - .then(port => { - if (port == null) { - // We have not found a port. - return; - } - const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; - const appName = require(paths.appPackageJson).name; - const urls = prepareUrls(protocol, HOST, port); - // Create a webpack compiler that is configured with custom messages. - const compiler = createCompiler(webpack, config, appName, urls, useYarn); - // Load proxy config - const proxySetting = require(paths.appPackageJson).proxy; - const proxyConfig = prepareProxy(proxySetting, paths.appPublic); - // Serve webpack assets generated by the compiler over a web sever. - const serverConfig = createDevServerConfig( - proxyConfig, - urls.lanUrlForConfig - ); - const devServer = new WebpackDevServer(compiler, serverConfig); - // Launch WebpackDevServer. - devServer.listen(port, HOST, err => { - if (err) { - return console.log(err); - } - if (isInteractive) { - clearConsole(); +configComposer(config).then(config => { + // Tools like Cloud9 rely on this. + const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; + const HOST = process.env.HOST || '0.0.0.0'; + + // We attempt to use the default port but if it is busy, we offer the user to + // run on a different port. `detect()` Promise resolves to the next free port. + choosePort(HOST, DEFAULT_PORT) + .then(port => { + if (port == null) { + // We have not found a port. + return; } - console.log(chalk.cyan('Starting the development server...\n')); - openBrowser(urls.localUrlForBrowser); - }); + const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; + const appName = require(paths.appPackageJson).name; + const urls = prepareUrls(protocol, HOST, port); + // Create a webpack compiler that is configured with custom messages. + const compiler = createCompiler(webpack, config, appName, urls, useYarn); + // Load proxy config + const proxySetting = require(paths.appPackageJson).proxy; + const proxyConfig = prepareProxy(proxySetting, paths.appPublic); + // Serve webpack assets generated by the compiler over a web sever. + const serverConfig = createDevServerConfig( + proxyConfig, + urls.lanUrlForConfig + ); + const devServer = new WebpackDevServer(compiler, serverConfig); + // Launch WebpackDevServer. + devServer.listen(port, HOST, err => { + if (err) { + return console.log(err); + } + if (isInteractive) { + clearConsole(); + } + console.log(chalk.cyan('Starting the development server...\n')); + openBrowser(urls.localUrlForBrowser); + }); - ['SIGINT', 'SIGTERM'].forEach(function(sig) { - process.on(sig, function() { - devServer.close(); - process.exit(); + ['SIGINT', 'SIGTERM'].forEach(function(sig) { + process.on(sig, function() { + devServer.close(); + process.exit(); + }); }); + }) + .catch(err => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); }); - }) - .catch(err => { - if (err && err.message) { - console.log(err.message); - } - process.exit(1); - }); +}); diff --git a/packages/react-scripts/template/src/index.dll.js b/packages/react-scripts/template/src/index.dll.js new file mode 100644 index 00000000000..7216c6458af --- /dev/null +++ b/packages/react-scripts/template/src/index.dll.js @@ -0,0 +1,5 @@ +// Import your dependencies here and we'll generate dll +// bundle for faster rebuilds and long term caching. +// Delete this file if you want to disable it. +import 'react'; +import 'react-dom';