From 01eca521711994d9689c9fad6a399fb2bd74d7f8 Mon Sep 17 00:00:00 2001 From: MariaAga Date: Thu, 14 Sep 2023 17:26:58 +0200 Subject: [PATCH] webpack 5 --- Procfile | 5 +- app/assets/config/manifest.js | 1 + app/assets/javascripts/late_load.js | 34 + app/helpers/application_helper.rb | 5 - app/helpers/layout_helper.rb | 22 + app/helpers/reactjs_helper.rb | 65 +- app/views/layouts/base.html.erb | 24 +- bundler.d/assets.rb | 2 +- config/environments/production.rb | 2 +- config/initializers/assets.rb | 32 +- config/webpack.config.js | 638 ++++++++++++------ developer_docs/getting-started.asciidoc | 14 +- lib/tasks/webpack_compile.rake | 5 +- package-exclude.json | 2 - package.json | 39 +- script/foreman-start-dev | 2 +- webpack/assets/javascripts/foreman_tools.js | 18 + .../react_app/common/AwaitedMount.js | 53 ++ .../react_app/common/MountingService.js | 14 +- .../react_app/common/globalIdHelpers.js | 2 + webpack/simple_named_modules.js | 35 - 21 files changed, 658 insertions(+), 356 deletions(-) create mode 100644 app/assets/javascripts/late_load.js create mode 100644 webpack/assets/javascripts/react_app/common/AwaitedMount.js delete mode 100644 webpack/simple_named_modules.js diff --git a/Procfile b/Procfile index f1791e16f5e..8211be6968d 100644 --- a/Procfile +++ b/Procfile @@ -2,4 +2,7 @@ # If you wish to use a different server then the default, use e.g. `export RAILS_STARTUP='puma -w 3 -p 3000 --preload'` rails: [ -n "$RAILS_STARTUP" ] && env PRY_WARNING=1 $RAILS_STARTUP || [ -n "$BIND" ] && bin/rails server -b $BIND || env PRY_WARNING=1 bin/rails server # you can use WEBPACK_OPTS to customize webpack server, e.g. 'WEBPACK_OPTS='--https --key /path/to/key --cert /path/to/cert.pem --cacert /path/to/cacert.pem' foreman start ' -webpack: [ -n "$NODE_ENV" ] && ./node_modules/.bin/webpack-dev-server-without-h2 --config config/webpack.config.js $WEBPACK_OPTS || env NODE_ENV=development ./node_modules/.bin/webpack-dev-server-without-h2 --config config/webpack.config.js $WEBPACK_OPTS +webpack: [ -n "$NODE_ENV" ] && npx webpack --config config/webpack.config.js --watch $WEBPACK_OPTS || env NODE_ENV=development npx webpack --config config/webpack.config.js --watch --analyze +#TODO readme/forklift to change/remove --key to --server-options-key +# --public -> --client-web-socket-url and see why it doesnt work +# --https --> --server-type diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index e06b86a6f2f..91678641616 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,3 +1,4 @@ +//= link late_load.js //= link_tree ../../../vendor/assets/fonts //= link_tree ../images //= link application.css diff --git a/app/assets/javascripts/late_load.js b/app/assets/javascripts/late_load.js new file mode 100644 index 00000000000..47fc57bc259 --- /dev/null +++ b/app/assets/javascripts/late_load.js @@ -0,0 +1,34 @@ +function load_dynamic_javascripts(html) { + function waitForAllLoaded() { + return new Promise(function(resolve) { + if (Object.values(window.allPluginsLoaded).every(Boolean)) { + resolve(); + } else { + function handleLoad() { + if (Object.values(window.allPluginsLoaded).every(Boolean)) { + resolve(); + // Remove the event listener + document.removeEventListener('loadPlugin', handleLoad); + } + } + document.addEventListener('loadPlugin', handleLoad); + } + }); + } + waitForAllLoaded().then(async function() { + var template = document.createElement('template'); + template.innerHTML = html; + var doc = new DOMParser().parseFromString(html, "text/html"); + var copyChildren = [...doc.head.children]; + for(var i = 0; i < copyChildren.length; i++) { + if(copyChildren[i].src){ + await import(copyChildren[i].src); + } + else{ + await eval(copyChildren[i].innerHTML) + } + } + const loadJS = new Event('loadJS'); + document.dispatchEvent(loadJS); + }); +} \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7b47676899f..eeeddcb0be5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -331,11 +331,6 @@ def hosts_count(resource_name = controller.resource_name) @hosts_count ||= HostCounter.new(resource_name) end - def webpack_dev_server - return unless Rails.configuration.webpack.dev_server.enabled - javascript_include_tag "#{@dev_server}/webpack-dev-server.js" - end - def accessible_resource_records(resource, order = :name) klass = resource.to_s.classify.constantize klass = klass.with_taxonomy_scope_override(@location, @organization) if klass.include? Taxonomix diff --git a/app/helpers/layout_helper.rb b/app/helpers/layout_helper.rb index 8ca268df7ce..d72566047a8 100644 --- a/app/helpers/layout_helper.rb +++ b/app/helpers/layout_helper.rb @@ -98,6 +98,28 @@ def javascript(*args) content_for(:javascripts) { javascript_include_tag(*args) } end + def javascript_include_tag(*params, **kwargs) + # Workaround for overriding javasctipt load with webpack_asset_paths, should be removed when webpack_asset_paths is removed + if kwargs.is_a?(Hash) && kwargs[:source] == "webpack_asset_paths" + kwargs[:webpacked] + else + super(*params, **kwargs) + end + end + + def webpack_asset_paths(plugin_name, extension: 'js') + if extension == 'js' + Foreman::Deprecation.deprecation_warning('3.9', '`webpack_asset_paths` is deprecated, use `content_for(:javascripts) { webpacked_plugins_js_for(plugin_name) }` instead.') + [{ + source: 'webpack_asset_paths', + webpacked: webpacked_plugins_js_for(plugin_name.to_sym), + }] + elsif extension == 'css' + Foreman::Deprecation.deprecation_warning('3.9', '`webpack_asset_paths` is deprecated and not needed for css assets.') + nil + end + end + # The target should have class="collapse [out|in]" out means collapsed on load and in means expanded. # Target must also have a unique id. def collapsing_header(title, target, collapsed = '') diff --git a/app/helpers/reactjs_helper.rb b/app/helpers/reactjs_helper.rb index 3799bee28a3..1695803c390 100644 --- a/app/helpers/reactjs_helper.rb +++ b/app/helpers/reactjs_helper.rb @@ -1,4 +1,3 @@ -require 'webpack-rails' module ReactjsHelper # Mount react component in views # Params: @@ -11,58 +10,60 @@ def react_component(name, props = {}) content_tag('foreman-react-component', '', :name => name, :data => { props: props }) end - def webpacked_plugins_with_global_css - global_css_tags(global_plugins_list).join.html_safe + def js_tags_for(requested_plugins) + requested_plugins.map do |plugin| + javascript_tag("window.tfm.tools.loadPluginModule('/webpack/#{plugin.to_s.tr('-', '_')}','#{plugin.to_s.tr('-', '_')}','./index');".html_safe) + end end def webpacked_plugins_js_for(*plugin_names) js_tags_for(select_requested_plugins(plugin_names)).join.html_safe end - def webpacked_plugins_with_global_js - global_js_tags(global_plugins_list).join.html_safe + def other_webpack_plugin(plugin_name, file) + javascript_tag("window.tfm.tools.loadPluginModule('/webpack/#{plugin_name.to_s.tr('-', '_')}','#{plugin_name.to_s.tr('-', '_')}','./#{file}_index');".html_safe) end - def webpacked_plugins_css_for(*plugin_names) - css_tags_for(select_requested_plugins(plugin_names)).join.html_safe + def global_js_tags(requested_plugins) + requested_plugins.map do |plugin| + plugin[:files].map do |file| + javascript_tag("window.tfm.tools.loadPluginModule('/webpack/#{plugin[:id].to_s.tr('-', '_')}','#{plugin[:id].to_s.tr('-', '_')}','./#{file}_index');".html_safe) + end + end end - def select_requested_plugins(plugin_names) - available_plugins = Foreman::Plugin.with_webpack.map(&:id) - missing_plugins = plugin_names - available_plugins - if missing_plugins.any? - logger.error { "Failed to include webpack assets for plugins: #{missing_plugins}" } - raise ::Foreman::Exception.new("Failed to include webpack assets for plugins: #{missing_plugins}") if Rails.env.development? - end - plugin_names & available_plugins + def webpacked_plugins_with_global_js + global_js_tags(global_plugins_list).join.html_safe end - def js_tags_for(requested_plugins) - requested_plugins.map do |plugin| - javascript_include_tag(*webpack_asset_paths(plugin.to_s, :extension => 'js')) - end + def webpacked_plugins_css_for(*plugin_names) + Foreman::Deprecation.deprecation_warning('3.9', '`webpacked_plugins_css_for` is deprecated, plugin css is already loaded.') end - def global_js_tags(requested_plugins) - requested_plugins.map do |plugin| - plugin[:files].map do |file| - javascript_include_tag(*webpack_asset_paths("#{plugin[:id]}:#{file}", :extension => 'js')) - end + def get_webpack_foreman_vendor_js + if ENV['RAILS_ENV'] == 'production' + javascript_include_tag("/webpack/#{File.basename(Dir.glob('public/webpack/foreman-vendor*production*js')[0])}") + else + javascript_include_tag("/webpack/#{File.basename(Dir.glob('public/webpack/foreman-vendor*development*js')[0])}") end end - def global_css_tags(requested_plugins) - requested_plugins.map do |plugin| - plugin[:files].map do |file| - stylesheet_link_tag(*webpack_asset_paths("#{plugin[:id]}:#{file}", :extension => 'css')) - end + def get_webpack_foreman_vendor_css + if ENV['RAILS_ENV'] == 'production' + stylesheet_link_tag("/webpack/#{File.basename(Dir.glob('public/webpack/foreman-vendor*production*css')[0])}") + else + stylesheet_link_tag("/webpack/#{File.basename(Dir.glob('public/webpack/foreman-vendor*development*css')[0])}") end end - def css_tags_for(requested_plugins) - requested_plugins.map do |plugin| - stylesheet_link_tag(*webpack_asset_paths(plugin.to_s, :extension => 'css')) + def select_requested_plugins(plugin_names) + available_plugins = Foreman::Plugin.with_webpack.map(&:id) + missing_plugins = plugin_names - available_plugins + if missing_plugins.any? + logger.error { "Failed to include webpack assets for plugins: #{missing_plugins}" } + raise ::Foreman::Exception.new("Failed to include webpack assets for plugins: #{missing_plugins}") if Rails.env.development? end + plugin_names & available_plugins end def locale_js_tags diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index c1f0f205bd0..fb852a7d169 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -9,11 +9,8 @@ <%= yield(:meta) %> <%= favicon_link_tag "favicon.ico"%> - - <%= stylesheet_link_tag *webpack_asset_paths('foreman-vendor', :extension => 'css') %> - <%= stylesheet_link_tag *webpack_asset_paths('bundle', :extension => 'css') %> + <%= get_webpack_foreman_vendor_css %> <%= stylesheet_link_tag 'application' %> - <%= webpacked_plugins_with_global_css %> <%= yield(:stylesheets) %> <%= csrf_meta_tags %> @@ -33,18 +30,21 @@ <%= javascript_include_tag "locale/#{FastGettext.locale}/app" %> <%= locale_js_tags %> - + <%= stylesheet_link_tag('/webpack/bundle', :extension => 'css') %> <%= yield(:head) %> + + <%= get_webpack_foreman_vendor_js %> + <%= javascript_include_tag('/webpack/vendor.js') %> + <%= javascript_include_tag('/webpack/bundle.js') %> - - <%= javascript_include_tag *webpack_asset_paths('foreman-vendor', :extension => 'js') %> - <%= javascript_include_tag *webpack_asset_paths('vendor', :extension => 'js') %> - <%= javascript_include_tag *webpack_asset_paths('bundle', :extension => 'js') %> <%= javascript_include_tag 'application' %> <%= webpacked_plugins_with_global_js %> - <%= webpack_dev_server %> - <%= yield(:javascripts) %> + <%= javascript_include_tag('late_load') %> + @@ -62,7 +62,7 @@ class="pf-c-page" > - <%= yield(:content) %> + <%= yield(:content) %> <% else %> <%= yield(:content) %> diff --git a/bundler.d/assets.rb b/bundler.d/assets.rb index 65bff536b50..dd23e51b5f2 100644 --- a/bundler.d/assets.rb +++ b/bundler.d/assets.rb @@ -4,7 +4,7 @@ gem 'gettext_i18n_rails_js', '~> 1.4' gem 'po_to_json', '~> 1.1' gem 'execjs', '>= 1.4.0', '< 3.0' - gem 'uglifier', '>= 1.0.3' + gem "terser", "~> 1.1" gem 'sass-rails', '~> 6.0' # this one is a dependecy for x-editable-rails gem 'coffee-rails', '~> 5.0.0' diff --git a/config/environments/production.rb b/config/environments/production.rb index f410ff8086f..9d318c29855 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -19,7 +19,7 @@ config.public_file_server.enabled = ENV.fetch('RAILS_SERVE_STATIC_FILES', false) == 'true' # Compress JavaScripts and CSS. - config.assets.js_compressor = :uglifier + config.assets.js_compressor = :terser # config.assets.css_compressor = :sass # Do not fallback to assets pipeline if a precompiled asset is missed. diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index bf5ad7054ea..79ce215c7ef 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -22,7 +22,7 @@ class << self # Precompile additional assets config.assets.precompile += FastGettext.default_available_locales.map { |loc| "locale/#{loc}/app.js" } - # Adds plugin assets to the application digests hash if a manifest file exists for a plugin + # Adds plugin assets to the application digests hash if a manifest file exists for a plugin config.after_initialize do if (manifest_file = Dir.glob("#{Rails.root}/public/assets/.sprockets-manifest*.json").first) foreman_manifest = JSON.parse(File.read(manifest_file)) @@ -53,35 +53,5 @@ class << self ActionView::Base.assets_manifest = app.assets_manifest end end - - # When the dev server is enabled, this static manifest file is ignored and - # always retrieved from the dev server. - # - # Otherwise we need to combine all the chunks from the various webpack - # manifests. This is the main foreman manifest and all plugins that may - # have one. We then store this in the webpack-rails manifest using our - # monkey patched function. - unless config.webpack.dev_server.enabled - if (webpack_manifest_file = Dir.glob("#{Rails.root}/public/webpack/manifest.json").first) - webpack_manifest = JSON.parse(File.read(webpack_manifest_file)) - - Foreman::Plugin.with_webpack.each do |plugin| - manifest_path = plugin.webpack_manifest_path - next unless manifest_path - - Rails.logger.debug { "Loading #{plugin.id} webpack asset manifest from #{manifest_path}" } - assets = JSON.parse(File.read(manifest_path)) - - plugin_id = plugin.id.to_s - assets['assetsByChunkName'].each do |chunk, filename| - if chunk == plugin_id || chunk.start_with?("#{plugin_id}:") - webpack_manifest['assetsByChunkName'][chunk] = filename - end - end - end - - Webpack::Rails::Manifest.manifest = webpack_manifest - end - end end end diff --git a/config/webpack.config.js b/config/webpack.config.js index 66a359ff2d5..2c632bf3ba3 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -3,27 +3,101 @@ var path = require('path'); var webpack = require('webpack'); +const dotenv = require('dotenv'); +dotenv.config(); var ForemanVendorPlugin = require('@theforeman/vendor') .WebpackForemanVendorPlugin; -var UglifyJsPlugin = require('uglifyjs-webpack-plugin'); var StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin; -var ExtractTextPlugin = require('extract-text-webpack-plugin'); -var CompressionPlugin = require('compression-webpack-plugin'); -var pluginUtils = require('../script/plugin_webpack_directories'); +var MiniCssExtractPlugin = require('mini-css-extract-plugin'); var vendorEntry = require('./webpack.vendor'); -var SimpleNamedModulesPlugin = require('../webpack/simple_named_modules'); -var argvParse = require('argv-parse'); var fs = require('fs'); -var OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); +const { ModuleFederationPlugin } = require('webpack').container; +var pluginUtils = require('../script/plugin_webpack_directories'); +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer') + .BundleAnalyzerPlugin; -var args = argvParse({ - port: { - type: 'string', - }, - host: { - type: 'string', - }, -}); +function getForemanFilePaths(isPlugin) { + const paths = [ + path.join( + process.cwd(), + './webpack/assets/javascripts/react_app/components/HostDetails/Templates' + ), + ]; + const acc = []; + paths.forEach(path => { + acc.push(...getForemanFilePath(isPlugin, path)); + }); + return acc; +} +function getForemanFilePath(isPlugin, directory) { + const files = fs.readdirSync(directory); + const filePaths = files.reduce((acc, file) => { + const filePath = path.join(directory, file); + const isFile = fs.statSync(filePath).isFile(); + if (isFile) { + if (file.endsWith('.js') && !file.includes('test')) { + if (isPlugin) { + acc.push( + path.join('../../foreman', filePath.replace(process.cwd(), '')) + ); + } else { + acc.push(path.join('./', filePath.replace(process.cwd(), ''))); + } + } + } else { + const subDirectoryFiles = getForemanFilePathsRecursive( + filePath, + isPlugin + ); + acc.push(...subDirectoryFiles); + } + return acc; + }, []); + + return filePaths; +} + +function getForemanFilePathsRecursive(directory, isPlugin) { + const files = fs.readdirSync(directory); + + const filePaths = files.reduce((acc, file) => { + const filePath = path.join(directory, file); + const isFile = fs.statSync(filePath).isFile(); + if (isFile) { + if (file.endsWith('.js') && !file.includes('test')) { + if (isPlugin) { + acc.push( + path.join('../../foreman', filePath.replace(process.cwd(), '')) + ); + } else { + acc.push(path.join('./', filePath.replace(process.cwd(), ''))); + } + } + } else { + const subDirectoryFiles = getForemanFilePathsRecursive( + filePath, + isPlugin + ); + acc.push(...subDirectoryFiles); + } + return acc; + }, []); + + return filePaths; +} +class AddRuntimeRequirement { + apply(compiler) { + compiler.hooks.compilation.tap('AddRuntimeRequirement', compilation => { + const { RuntimeGlobals } = compiler.webpack; + compilation.hooks.additionalModuleRuntimeRequirements.tap( + 'AddRuntimeRequirement', + (module, set) => { + set.add(RuntimeGlobals.loadScript); + } + ); + }); + } +} const supportedLocales = () => { const localeDir = path.join(__dirname, '..', 'locale'); @@ -42,111 +116,61 @@ const supportedLanguages = () => { return [...new Set(supportedLocales().map(d => d.split('_')[0]))]; }; -const devServerConfig = () => { - const result = require('dotenv').config(); - if (result.error && result.error.code !== 'ENOENT') { - throw result.error; - } +const supportedLanguagesRE = new RegExp( + `/(${supportedLanguages().join('|')})$` +); - return { - port: args.port || '3808', - host: args.host || process.env.BIND || 'localhost', - }; -}; - -module.exports = env => { - const devServer = devServerConfig(); - - // set TARGETNODE_ENV=production on the environment to add asset fingerprints +var bundleEntry = path.join( + __dirname, + '..', + 'webpack/assets/javascripts/bundle.js' +); +const commonConfig = function(env, argv) { var production = process.env.RAILS_ENV === 'production' || process.env.NODE_ENV === 'production'; - - var bundleEntry = path.join( - __dirname, - '..', - 'webpack/assets/javascripts/bundle.js' - ); - - var plugins = pluginUtils.getPluginDirs('pipe'); - - var resolveModules = [ - path.join(__dirname, '..', 'webpack'), - path.join(__dirname, '..', 'node_modules'), - 'node_modules/', - ].concat(pluginUtils.pluginNodeModules(plugins)); - - if (env && env.pluginName !== undefined) { - var pluginEntries = {}; - pluginEntries[env.pluginName] = plugins['entries'][env.pluginName]; - for (var entry of Object.keys(plugins['entries'])) { - if (entry.startsWith(env.pluginName + ':')) { - pluginEntries[entry] = plugins['entries'][entry]; - } - } - - var outputPath = path.join( - plugins['plugins'][env.pluginName]['root'], - 'public', - 'webpack' - ); - var jsFilename = production - ? env.pluginName + '/[name]-[chunkhash].js' - : env.pluginName + '/[name].js'; - var cssFilename = production - ? env.pluginName + '/[name]-[chunkhash].css' - : env.pluginName + '/[name].css'; - var chunkFilename = production - ? env.pluginName + '/[name]-[chunkhash].js' - : env.pluginName + '/[name].js'; - var manifestFilename = env.pluginName + '/manifest.json'; + const mode = production ? 'production' : 'development'; + const config = {}; + if (production) { + config.devtool = 'source-map'; + config.optimization = { + moduleIds: 'named', + splitChunks: false, + }; } else { - var pluginEntries = plugins['entries']; - var outputPath = path.join(__dirname, '..', 'public', 'webpack'); - var jsFilename = production ? '[name]-[chunkhash].js' : '[name].js'; - var cssFilename = production ? '[name]-[chunkhash].css' : '[name].css'; - var chunkFilename = production ? '[name]-[chunkhash].js' : '[name].js'; - var manifestFilename = 'manifest.json'; + config.optimization = { + splitChunks: false, + }; + config.devtool = 'inline-source-map'; } - - var entry = Object.assign( - { - bundle: bundleEntry, - vendor: vendorEntry, - }, - pluginEntries - ); - - const supportedLanguagesRE = new RegExp( - `/(${supportedLanguages().join('|')})$` - ); - - var config = { - entry: entry, - output: { - // Build assets directly in to public/webpack/, let webpack know - // that all webpacked assets start with webpack/ - - // must match config.webpack.output_dir - path: outputPath, - publicPath: '/webpack/', - filename: jsFilename, - chunkFilename, - }, + return { + ...config, + mode, resolve: { - modules: resolveModules, - alias: Object.assign( - { - foremanReact: path.join( - __dirname, - '../webpack/assets/javascripts/react_app' - ), - }, - pluginUtils.aliasPlugins(pluginEntries) - ), + fallback: { + path: require.resolve('path-browserify'), + os: require.resolve('os-browserify'), + }, + alias: { + foremanReact: path.join( + __dirname, + '../webpack/assets/javascripts/react_app' + ), + '@theforeman/vendor': path.join( + __dirname, + '..', + '..', + 'foreman', + 'node_modules', + '@theforeman', + 'vendor' + ), + }, + }, + resolveLoader: { + modules: [path.resolve(__dirname, '..', 'node_modules')], }, - module: { rules: [ { @@ -159,25 +183,14 @@ module.exports = env => { presets: [require.resolve('@theforeman/builder/babel')], }, }, - { - test: /\.css$/, - use: ExtractTextPlugin.extract({ - fallback: 'style-loader', - use: 'css-loader', - }), - }, { test: /\.(png|gif|svg)$/, - use: 'url-loader?limit=32767', - }, - { - test: /\.scss$/, - use: ExtractTextPlugin.extract({ - fallback: 'style-loader', // The backup style loader - use: production - ? 'css-loader!sass-loader' - : 'css-loader?sourceMap!sass-loader?sourceMap', - }), + type: 'asset', + parser: { + dataUrlCondition: { + maxSize: 32767, + }, + }, }, { test: /\.(graphql|gql)$/, @@ -186,48 +199,13 @@ module.exports = env => { }, ], }, - plugins: [ new ForemanVendorPlugin({ - mode: production ? 'production' : 'development', - }), - // must match config.webpack.manifest_filename - new StatsWriterPlugin({ - filename: manifestFilename, - fields: null, - transform: function(data, opts) { - return JSON.stringify( - { - assetsByChunkName: data.assetsByChunkName, - errors: data.errors, - warnings: data.warnings, - }, - null, - 2 - ); - }, - }), - new ExtractTextPlugin({ - filename: cssFilename, - allChunks: true, - }), - new OptimizeCssAssetsPlugin({ - assetNameRegExp: /\.css$/g, - cssProcessor: require('cssnano'), - cssProcessorPluginOptions: { - preset: [ - 'default', - { - discardComments: { removeAll: true }, - discardDuplicates: { removeAll: true }, - }, - ], - }, - canPrint: true, + mode, }), new webpack.DefinePlugin({ 'process.env': { - NODE_ENV: JSON.stringify(production ? 'production' : 'development'), + NODE_ENV: JSON.stringify(mode), NOTIFICATIONS_POLLING: process.env.NOTIFICATIONS_POLLING, REDUX_LOGGER: process.env.REDUX_LOGGER, }, @@ -242,46 +220,316 @@ module.exports = env => { /react-intl\/locale-data/, supportedLanguagesRE ), + new AddRuntimeRequirement(), + // new webpack.optimize.ModuleConcatenationPlugin(), ], + infrastructureLogging: { + colors: true, + level: 'verbose', + }, + stats: { + logging: 'verbose', + preset: 'verbose', + }, + }; +}; + +const moduleFederationSharedConfig = function(env, argv) { + return { + react: { singleton: true }, + 'react-dom': { singleton: true }, + '@theforeman/vendor': { singleton: true }, + '@theforeman/vendor-dev': { singleton: true }, + '@theforeman/vendor-scss': { singleton: true }, + '@theforeman/vendor-scss-variables': { singleton: true }, + '@babel/core': { singleton: true }, + webpack: { singleton: true }, + 'bundle.js': { singleton: true }, + 'path-browserify': { singleton: true }, + 'os-browserify': { singleton: true }, + }; +}; + +const coreConfig = function(env, argv) { + var config = commonConfig(env, argv); + + var manifestFilename = 'manifest.json'; + config.context = path.resolve(__dirname, '..'); + config.entry = { + bundle: { import: bundleEntry, dependOn: 'vendor' }, + vendor: vendorEntry, }; + config.output = { + path: path.join(__dirname, '..', 'public', 'webpack'), + publicPath: '/webpack/', + }; + var plugins = config.plugins; + console.log( + 'path core', + path.resolve( + __dirname, + '..', + 'webpack', + 'assets', + 'javascripts', + 'react_app', + 'components' + ) + ); + + const webpackDirectory = path.resolve( + __dirname, + '..', + 'webpack', + 'assets', + 'javascripts', + 'react_app' + ); + const coreShared = {}; + + getForemanFilePaths(false).forEach(file => { + const key = file.replace(webpackDirectory, '').replace(/\.(js|jsx)$/, ''); + coreShared['./' + file] = { + singleton: true, + eager: true, + shareKey: key, + }; + }); + plugins.push( + new ModuleFederationPlugin({ + name: 'foremanReact', + shared: { + ...moduleFederationSharedConfig(env, argv), - config.plugins.push( - new webpack.optimize.CommonsChunkPlugin({ - name: 'vendor', - minChunks: Infinity, + ...coreShared, + }, + }) + ); + plugins.push( + new MiniCssExtractPlugin({ + ignoreOrder: true, + filename: '[name].css', + chunkFilename: '[id].css', + }) + ); + plugins.push( + new StatsWriterPlugin({ + filename: manifestFilename, + fields: null, + transform: function(data, opts) { + return JSON.stringify( + { + assetsByChunkName: data.assetsByChunkName, + errors: data.errors, + warnings: data.warnings, + }, + null, + 2 + ); + }, }) ); - if (production) { - config.plugins.push( - new webpack.NoEmitOnErrorsPlugin(), - new UglifyJsPlugin({ - uglifyOptions: { - compress: { warnings: false }, + plugins.push( + new BundleAnalyzerPlugin({ + generateStatsFile: true, + analyzerMode: 'static', + openAnalyzer: false, + statsFilename: 'stats.json', + }) + ); + config.plugins = plugins; + var rules = config.module.rules; + rules.push({ + test: /\.(sa|sc|c)ss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + options: { + publicPath: path.join(__dirname, '..', 'public', 'webpack'), }, - sourceMap: true, - }), - new SimpleNamedModulesPlugin(), - new webpack.optimize.ModuleConcatenationPlugin(), - new webpack.optimize.OccurrenceOrderPlugin(), - new CompressionPlugin() - ); - config.devtool = 'source-map'; - } else { - config.plugins.push( - new webpack.HotModuleReplacementPlugin() // Enable HMR + }, + 'css-loader', + 'sass-loader', + ], + }); + config.module.rules = rules; + return config; +}; + +const pluginConfig = function(env, argv) { + var pluginEnv = env.plugin; + const pluginRoot = pluginEnv.root; + const pluginName = pluginEnv.name.replace('-', '_'); // module federation doesnt like - + + console.log('pluginEntries', pluginEnv.entries); + + var config = commonConfig(env, argv); + console.log('pluginEnv', pluginEnv); + console.log('pluginEnv root', pluginRoot); + config.context = path.join(pluginRoot, 'webpack'); + config.entry = {}; + var pluginEntries = { + './index': path.resolve(pluginRoot, 'webpack', 'index'), + }; + pluginEnv.entries.filter(Boolean).forEach(entry => { + pluginEntries[`./${entry}_index`] = path.resolve( + pluginRoot, + 'webpack', + `${entry}_index` ); + }); + + config.output = { + path: path.join(__dirname, '..', 'public', 'webpack', pluginName), + publicPath: '/webpack/' + pluginName + '/', + uniqueName: pluginName, + }; + + var configModules = config.resolve.modules || []; + // make webpack to resolve modules from core first + configModules.unshift(path.resolve(__dirname, '..', 'node_modules')); + // add plugin's node_modules to the reslver list + configModules.push(path.resolve(pluginRoot, 'node_modules')); + config.resolve.modules = configModules; + + //get the list of webpack plugins + var plugins = config.plugins; + console.log('path plugin', path.resolve(pluginRoot, 'webpack', 'index.js')); + + const webpackDirectory = path.resolve( + __dirname, + '..', + 'webpack', + 'assets', + 'javascripts', + 'react_app' + ); - config.devServer = { - host: devServer.host, - port: devServer.port, - headers: { 'Access-Control-Allow-Origin': '*' }, - hot: true, - stats: (process.env.WEBPACK_STATS || 'minimal'), + const pluginShared = {}; + getForemanFilePaths(true).forEach(file => { + const key = file + .replace(webpackDirectory, '') + .replace(/\.(js|jsx)$/, '') + .replace('../../foreman/', ''); + pluginShared[file] = { + singleton: true, + shareKey: key, }; - // Source maps - config.devtool = 'inline-source-map'; - } + }); + const keys = [...Object.keys(pluginShared)]; + const newObj = {}; + console.log('newKeys', keys); + keys.forEach(key => { + newObj[key] = pluginShared[key]; + }); + plugins.push( + new ModuleFederationPlugin({ + name: pluginName, + filename: pluginName + '_remoteEntry.js', + shared: { + ...moduleFederationSharedConfig(env, argv), + ...pluginShared, + }, + exposes: pluginEntries, + }) + ); + plugins.push( + new MiniCssExtractPlugin({ + ignoreOrder: true, + filename: pluginName + '/[name].css', + chunkFilename: pluginName + '/[id].css', + }) + ); + const manifestFilename = pluginName + '_manifest.json'; + plugins.push( + new StatsWriterPlugin({ + filename: manifestFilename, + fields: null, + transform: function(data, opts) { + return JSON.stringify( + { + assetsByChunkName: data.assetsByChunkName, + errors: data.errors, + warnings: data.warnings, + }, + null, + 2 + ); + }, + }) + ); + plugins.push( + new BundleAnalyzerPlugin({ + generateStatsFile: true, + analyzerMode: 'static', + openAnalyzer: false, + statsFilename: pluginName + '_stats.json', + }) + ); + config.plugins = plugins; + var rules = config.module.rules; + rules.push({ + test: /\.(sa|sc|c)ss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + 'css-loader', + 'sass-loader', + ], + }); + config.module.rules = rules; + config.optimization = { + ...config.optimization, + }; return config; }; + +module.exports = function(env, argv) { + var pluginsDirs = pluginUtils.getPluginDirs('pipe'); + var pluginsInfo = {}; + var pluginsConfigEnv = []; + var pluginDirKeys = Object.keys(pluginsDirs.plugins); + pluginDirKeys.forEach(pluginDirKey => { + const parts = pluginDirKey.split(':'); + const name = parts[0]; + const entry = parts[1]; + if (pluginsInfo[name]) { + pluginsInfo[name].entries.push(entry); + } else { + pluginsInfo[name] = { + name, + entries: [entry], + root: pluginsDirs.plugins[pluginDirKey].root, + }; + } + if (!pluginDirKey.includes(':')) { + console.log( + 'has global', + pluginDirKey, + pluginDirKeys.includes(pluginDirKey + ':global') + ); + pluginsConfigEnv.push({ + plugin: { + routes: pluginDirKeys.includes(pluginDirKey + ':routes'), // TODO load??? + global: pluginDirKeys.includes(pluginDirKey + ':global'), + name: pluginDirKey, + root: pluginsDirs.plugins[pluginDirKey].root, + }, + }); + } + }); + + console.log('pluginsConfigEnv', pluginsConfigEnv); + console.log('pluginsInfo', pluginsInfo); + let configs = []; + const pluginsInfoValues = Object.values(pluginsInfo); + if (pluginsInfoValues.length > 0) { + configs = pluginsInfoValues.map(plugin => + pluginConfig({ ...env, plugin }, argv) + ); + } + return [coreConfig(env, argv), ...configs]; +}; diff --git a/developer_docs/getting-started.asciidoc b/developer_docs/getting-started.asciidoc index 843502df315..7d5b026e2f3 100644 --- a/developer_docs/getting-started.asciidoc +++ b/developer_docs/getting-started.asciidoc @@ -36,10 +36,8 @@ Following steps are required to setup a webpack development environment: - using `script/foreman-start-dev` (starts rails and webpack server) - executing rails and webpack processes "manually" ```bash - ./node_modules/.bin/webpack-dev-server \ + npx webpack \ --config config/webpack.config.js \ - --port 3808 \ - --public $(hostname):3808 ``` 4. **Additional config** @@ -48,15 +46,9 @@ Following steps are required to setup a webpack development environment: + [source,bash] ---- - ./node_modules/.bin/webpack-dev-server \ + npx webpack \ --config config/webpack.config.js \ - --port 3808 \ - --public $(hostname):3808 \ - --https \ - --key /etc/pki/katello/private/katello-apache.key \ - --cert /etc/pki/katello/certs/katello-apache.crt \ - --cacert /etc/pki/katello/certs/katello-default-ca.crt \ - --watch-poll 1000 # only use for NFS https://community.theforeman.org/t/webpack-watch-over-nfs/10922 + --watchOptions-poll 1000 # only use for NFS https://community.theforeman.org/t/webpack-watch-over-nfs/10922 ---- + Additionally you can set `NOTIFICATIONS_POLLING` variable to extend the notification polling interval that is 10s by default and can clutter the console. diff --git a/lib/tasks/webpack_compile.rake b/lib/tasks/webpack_compile.rake index 9942a4cfff4..d6a3bb627b7 100644 --- a/lib/tasks/webpack_compile.rake +++ b/lib/tasks/webpack_compile.rake @@ -1,6 +1,3 @@ -# We need to delete the existing task which comes from webpack-rails gem or this task will get executed twice -Rake::Task['webpack:compile'].clear - namespace :webpack do # TODO: remove after migrating away from webpack-rails (after setting the # max_old_space_size) in other tool. @@ -23,6 +20,6 @@ namespace :webpack do raise "Can't find our webpack config file at #{config_file}" end - sh "node --max_old_space_size=#{max_old_space_size} #{webpack_bin} --config #{config_file} --bail" + sh "npx --max_old_space_size=#{max_old_space_size} #{webpack_bin} --config #{config_file} --bail" end end diff --git a/package-exclude.json b/package-exclude.json index c861a9a44fa..83a56884577 100644 --- a/package-exclude.json +++ b/package-exclude.json @@ -30,8 +30,6 @@ "redux-mock-store", "surge", "webpack-bundle-analyzer", - "webpack-dev-server", - "webpack-dev-server-without-h2", "tabbable", "@adobe/css-tools", "sass" diff --git a/package.json b/package.json index c99562e59c6..7e66f444b7c 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,14 @@ "analyze": "./script/webpack-analyze" }, "dependencies": { + "@module-federation/utilities": "^1.7.0", "@theforeman/vendor": "^12.0.1", "graphql-tag": "^2.11.0", "intl": "~1.2.5", "jed": "^1.1.1", - "react-intl": "^2.8.0" + "os-browserify": "^0.3.0", + "react-intl": "^2.8.0", + "stream-browserify": "^3.0.0" }, "devDependencies": { "@adobe/css-tools": "~4.2.0", @@ -38,20 +41,21 @@ "argv-parse": "^1.0.1", "babel-eslint": "^10.0.0", "babel-loader": "^8.0.0", - "compression-webpack-plugin": "~1.1.11", "cross-env": "^5.2.0", - "css-loader": "^0.23.1", + "css-loader": "4.3.0", + "css-minimizer-webpack-plugin": "^4.2.2", "cssnano": "^4.1.10", "dotenv": "^5.0.0", "eslint": "^6.7.2", "eslint-plugin-spellcheck": "0.0.17", - "expose-loader": "~0.6.0", - "extract-text-webpack-plugin": "^3.0.0", - "file-loader": "^0.9.0", "graphql": "^15.5.0", "highlight.js": "~9.14.0", - "node-sass": "^4.5.0", - "optimize-css-assets-webpack-plugin": "^3.2.0", + "mini-css-extract-plugin": "2.7.5", + "node-sass": "6.0.0", + "optimize-css-assets-webpack-plugin": "^4.0.0", + "path-browserify": "^1.0.1", + "postcss-loader": "4.3.0", + "postcss": "^8.4.12", "prettier": "^1.19.1", "pretty-format": "26.6.2", "raw-loader": "^0.5.1", @@ -59,17 +63,16 @@ "react-dnd-test-utils": "^9.4.0", "react-remarkable": "^1.1.3", "redux-mock-store": "^1.2.2", - "sass-loader": "~6.0.6", - "style-loader": "^0.13.1", - "stylelint": "^9.3.0", + "sass-loader": "10.2.0", + "sass": "~1.60.0", + "style-loader": "1.3.0", "stylelint-config-standard": "^18.0.0", - "uglifyjs-webpack-plugin": "^1.2.2", - "url-loader": "^1.0.1", - "webpack": "^3.4.1", - "webpack-bundle-analyzer": ">=3.3.2", - "webpack-dev-server-without-h2": "^2.11.8", - "webpack-stats-plugin": "^0.1.5", + "stylelint": "^9.3.0", "tabbable": "~5.2.0", - "sass": "~1.60.0" + "url-loader": "4.1.1", + "webpack-bundle-analyzer": "^4.5.0", + "webpack-cli": "5.0.1", + "webpack-stats-plugin": "^1.0.3", + "webpack": "5.75.0" } } diff --git a/script/foreman-start-dev b/script/foreman-start-dev index f80ac97638e..a93987326d7 100755 --- a/script/foreman-start-dev +++ b/script/foreman-start-dev @@ -1,3 +1,3 @@ #!/bin/sh -./node_modules/.bin/webpack-dev-server-without-h2 --config config/webpack.config.js --host "::" $WEBPACK_OPTS & +npx webpack --config config/webpack.config.js --watch $WEBPACK_OPTS & ./bin/rails server -b \[::\] "$@" diff --git a/webpack/assets/javascripts/foreman_tools.js b/webpack/assets/javascripts/foreman_tools.js index 7f4fa5464c8..dd835b05cb7 100644 --- a/webpack/assets/javascripts/foreman_tools.js +++ b/webpack/assets/javascripts/foreman_tools.js @@ -6,6 +6,7 @@ /* eslint-disable jquery/no-class */ import $ from 'jquery'; +import { importRemote } from '@module-federation/utilities'; import { sprintf, translate as __ } from './react_app/common/I18n'; import { showLoading, hideLoading } from './foreman_navigation'; @@ -166,3 +167,20 @@ export function highlightTabErrors() { .find('.form-control') .focus(); } + +export const loadPluginModule = async (url, scope, module, plugin = true) => { + if (!window.allPluginsLoaded) { + window.allPluginsLoaded = {}; + } + const name = `${scope}${module}`; + window.allPluginsLoaded[name] = false; + await importRemote({ + url, + scope, + module, + remoteEntryFileName: plugin ? `${scope}_remoteEntry.js` : 'remoteEntry.js', + }); + window.allPluginsLoaded[name] = true; + const loadPlugin = new Event('loadPlugin'); + document.dispatchEvent(loadPlugin); +}; diff --git a/webpack/assets/javascripts/react_app/common/AwaitedMount.js b/webpack/assets/javascripts/react_app/common/AwaitedMount.js new file mode 100644 index 00000000000..89c17bd890c --- /dev/null +++ b/webpack/assets/javascripts/react_app/common/AwaitedMount.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React, { useState, useEffect } from 'react'; +import store from '../redux'; +import componentRegistry from '../components/componentRegistry'; +import { translate as __ } from './I18n'; + +export const AwaitedMount = ({ component, data, flattenData }) => { + const [mounted, setMounted] = useState(false); + const [mountedComponent, setMountedComponent] = useState(null); + const [allPluginsImported, setAllPluginsImported] = useState(false); + async function mountComponent() { + if (componentRegistry.registry[component]) { + setMounted(true); + setMountedComponent( + componentRegistry.markup(component, { + data, + store, + flattenData, + }) + ); + } else if (allPluginsImported) { + const awaitedComponent = componentRegistry.markup(component, { + data, + store, + flattenData, + }); + setMounted(true); + setMountedComponent(awaitedComponent); + } + } + const updateAllPluginsImported = e => { + setAllPluginsImported(true); + }; + useEffect(() => { + document.addEventListener('loadJS', updateAllPluginsImported); + return () => window.removeEventListener('loadJS', updateAllPluginsImported); + }, []); + useEffect(() => { + if (!mounted) mountComponent(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allPluginsImported]); + return mounted ? mountedComponent :
{__('Loading...')}
; +}; + +AwaitedMount.propTypes = { + component: PropTypes.string.isRequired, + data: PropTypes.object, + flattenData: PropTypes.bool, +}; +AwaitedMount.defaultProps = { + data: {}, + flattenData: false, +}; diff --git a/webpack/assets/javascripts/react_app/common/MountingService.js b/webpack/assets/javascripts/react_app/common/MountingService.js index ee87e5335f1..ba6f8272322 100644 --- a/webpack/assets/javascripts/react_app/common/MountingService.js +++ b/webpack/assets/javascripts/react_app/common/MountingService.js @@ -1,16 +1,16 @@ import ReactDOM from 'react-dom'; -import store from '../redux'; -import componentRegistry from '../components/componentRegistry'; +import React from 'react'; +import { AwaitedMount } from './AwaitedMount'; export { default as registerReducer } from '../redux/reducers/registerReducer'; function mountNode(component, reactNode, data, flattenData) { ReactDOM.render( - componentRegistry.markup(component, { - data, - store, - flattenData, - }), + , reactNode ); } diff --git a/webpack/assets/javascripts/react_app/common/globalIdHelpers.js b/webpack/assets/javascripts/react_app/common/globalIdHelpers.js index 20204ca16ac..59be48e9b62 100644 --- a/webpack/assets/javascripts/react_app/common/globalIdHelpers.js +++ b/webpack/assets/javascripts/react_app/common/globalIdHelpers.js @@ -7,6 +7,8 @@ * together into a single string and encoded as base64. */ +import { Buffer } from 'buffer'; + const idSeparator = '-'; const versionSeparator = ':'; const defaultVersion = '01'; diff --git a/webpack/simple_named_modules.js b/webpack/simple_named_modules.js deleted file mode 100644 index 8365cd7573f..00000000000 --- a/webpack/simple_named_modules.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - Simple Named Modules Plugin - Strips relative path up to node_modules/ from the module ID. - This allows for consistent module IDs when building webpack bundles from - differing base paths relative to the node_modules directory. - - Based on NamedModulesPlugin by Tobias Koppers @sokra, originally licensed under - MIT License: http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -class SimpleNamedModulesPlugin { - constructor(options) { - this.options = options || {}; - } - - apply(compiler) { - compiler.plugin("compilation", (compilation) => { - compilation.plugin("before-module-ids", (modules) => { - modules.forEach((module) => { - if(module.id === null && module.libIdent) { - module.id = module.libIdent({ - context: this.options.context || compiler.options.context - }); - if (module.id.includes('node_modules')) { - module.id = module.id.slice(module.id.indexOf('node_modules')) - } - } - }); - }); - }); - } -} - -module.exports = SimpleNamedModulesPlugin;