UNPKG

@eclipse-scout/cli

Version:
628 lines (587 loc) 23 kB
/* * Copyright (c) 2010, 2024 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ const fs = require('fs'); const path = require('path'); const scoutBuildConstants = require('./constants'); const CopyPlugin = require('copy-webpack-plugin'); const {CycloneDxWebpackPlugin} = require('@cyclonedx/webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const AfterEmitWebpackPlugin = require('./AfterEmitWebpackPlugin'); const {SourceMapDevToolPlugin, WatchIgnorePlugin, ProgressPlugin} = require('webpack'); /** * @param {string} args.mode development or production * @param {boolean} args.clean true, to clean the dist folder before each build. Default is false. * @param {boolean} args.progress true, to show build progress in percentage. Default is true. * @param {boolean} args.profile true, to show timing information for each build step. Default is false. * @param {boolean} args.watch true, if webpack runs in watch mode. Default is false. * @param {boolean} args.cyclonedxSkip true, if no CycloneDX SBOM should be created. Default is false. * @param {string} args.cyclonedxVersion CycloneDX version to use. Default is '1.5'. * @param {[]} args.resDirArray an array containing directories which should be copied to dist/res * @param {object} args.tsOptions a config object to be passed to the ts-loader * @param {boolean|'fork'} args.typeCheck * true: let the TypeScript compiler check the types. * false: let the TypeScript compiler only transpile the TypeScript code without checking types, which makes it faster. * fork: starts a separate process to run the type checks so that the build won't be blocked until type check completes. * Also shows a notification if type check fails. This mode needs more memory. * auto: * - In prod mode, types won't be checked (typeCheck is false). * - In dev mode, types will be checked (typeCheck is true). * - In watch mode: types will be checked in a separate process (typeCheck is fork). */ module.exports = (env, args) => { const buildMode = args.mode; const {devMode, cssFilename, jsFilename} = scoutBuildConstants.getConstantsForMode(buildMode); const isMavenModule = scoutBuildConstants.isMavenModule(); const isWatchMode = nvl(args.watch, false); const outDir = scoutBuildConstants.getOutputDir(buildMode); const resDirArray = args.resDirArray || ['res']; let typeCheck = computeTypeCheck(args.typeCheck, devMode, isWatchMode); console.log(`Webpack mode: ${buildMode}`); if (isWatchMode) { console.log('File watching enabled'); } console.log(`Type check: ${typeCheck}`); // # Copy static web-resources delivered by the modules const copyPluginConfig = []; const copyTarget = isMavenModule ? '../res' : '.'; for (const resDir of resDirArray) { copyPluginConfig.push( { from: resDir, to: copyTarget }); } const minimizerTarget = ['firefox69', 'chrome71', 'safari13']; const babelOptions = { compact: false, cacheDirectory: true, cacheCompression: false, presets: [ [require.resolve('@babel/preset-env'), { debug: false, targets: { firefox: '69', chrome: '71', safari: '13' } }] ] }; const transpileOnly = typeCheck === 'fork' ? true : !typeCheck; const tsOptions = { ...args.tsOptions, transpileOnly: transpileOnly, compilerOptions: { noEmit: false, ...args.tsOptions?.compilerOptions } }; const config = { mode: buildMode, devtool: false, // disabled because SourceMapDevToolPlugin is used (see below) ignoreWarnings: [(webpackError, compilation) => isWarningIgnored(devMode, webpackError, compilation)], resolve: { // no automatic polyfills. clients must add the desired polyfills themselves. fallback: { assert: false, buffer: false, console: false, constants: false, crypto: false, domain: false, events: false, http: false, https: false, os: false, path: false, punycode: false, process: false, querystring: false, stream: false, string_decoder: false, sys: false, timers: false, tty: false, url: false, util: false, vm: false, zlib: false }, extensions: ['.ts', '.js', '.json', '.wasm', '.tsx', '.jsx'] }, // expect these apis in the browser externals: { 'crypto': 'crypto', 'canvas': 'canvas', 'fs': 'fs', 'http': 'http', 'https': 'https', 'url': 'url', 'zlib': 'zlib' }, output: { filename: jsFilename, path: outDir, clean: nvl(args.clean, false) }, performance: { hints: false }, profile: args.profile, module: { rules: [{ // LESS test: /\.less$/, use: [{ // Extracts CSS into separate files. It creates a CSS file per JS file which contains CSS. // It supports On-Demand-Loading of CSS and SourceMaps. // see: https://webpack.js.org/plugins/mini-css-extract-plugin/ // // Note: this creates some useless *.js files, like dark-theme.js // This seems to be an issue in webpack, workaround is to remove the files later // see: https://github.com/webpack-contrib/mini-css-extract-plugin/issues/151 // seems to be fixed in webpack 5, workaround to manually delete js files can be removed as soon as webpack 5 is released loader: MiniCssExtractPlugin.loader }, { // Interprets @import and url() like import/require() and will resolve them. // see: https://webpack.js.org/loaders/css-loader/ loader: require.resolve('css-loader'), options: { sourceMap: devMode, modules: false, // We don't want to work with CSS modules url: false // Don't resolve URLs in LESS, because relative path does not match /res/fonts } }, { // Compiles Less to CSS. // see: https://webpack.js.org/loaders/less-loader/ loader: require.resolve('less-loader'), options: { sourceMap: devMode, lessOptions: { relativeUrls: false, rewriteUrls: 'off', math: 'always' } } }] }, { test: /\.tsx?$/, exclude: /node_modules/, use: [{ loader: require.resolve('babel-loader'), options: babelOptions }, { loader: require.resolve('ts-loader'), options: tsOptions }] }, { test: /\.jsx?$/, use: [{ loader: require.resolve('babel-loader'), options: babelOptions }] }, { test: /\.jsx?$/, enforce: 'pre', use: [{ loader: require.resolve('source-map-loader') }] }] }, plugins: [ new WatchIgnorePlugin({paths: [/\.d\.ts$/]}), // see: extracts css into separate files new MiniCssExtractPlugin({filename: cssFilename}), // run post-build script hook new AfterEmitWebpackPlugin({outDir: outDir}), new SourceMapDevToolPlugin({ // Use external source maps in all modes because the browser is very slow in displaying a file containing large lines which is the case if source maps are inlined filename: '[file].map', // Don't create maps for static resources. // They may already have maps which could lead to "multiple assets emit different content to the same file" exception. exclude: /\/res\/.*/, // In production mode create external source maps without source code to map stack traces. // Otherwise, stack traces would point to the minified source code which makes it quite impossible to analyze productive issues. noSources: !devMode, moduleFilenameTemplate: devMode ? undefined : prodDevtoolModuleFilenameTemplate }) ], optimization: { splitChunks: { chunks: 'all', name: (module, chunks, cacheGroupKey) => computeChunkName(module, chunks, cacheGroupKey) } } }; // Copy resources only add the plugin if there are resources to copy. Otherwise, the plugin fails. if (copyPluginConfig.length > 0) { config.plugins.push(new CopyPlugin({patterns: copyPluginConfig})); } // Shows progress information in the console in dev mode if (nvl(args.progress, true)) { config.plugins.push(new ProgressPlugin({profile: args.profile})); } if (typeCheck === 'fork') { // perform type checks asynchronously in a separate process const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); const ForkTsCheckerNotifierWebpackPlugin = require('fork-ts-checker-notifier-webpack-plugin'); let forkTsCheckerConfig = { typescript: { memoryLimit: 4096 } }; if (!fs.existsSync('./tsconfig.json')) { // if the module has no tsconfig: use default from Scout. // Otherwise, each module would need to provide a tsconfig even if there is no typescript code in the module. forkTsCheckerConfig = { typescript: { ...forkTsCheckerConfig.typescript, configFile: require.resolve('@eclipse-scout/tsconfig'), context: process.cwd(), configOverwrite: { compilerOptions: {skipLibCheck: true, sourceMap: false, inlineSourceMap: false, declarationMap: false, allowJs: true}, include: isMavenModule ? ['./src/main/js/**/*.ts', './src/main/js/**/*.js', './src/test/js/**/*.ts', './src/test/js/**/*.js'] : ['./src/**/*.ts', './src/**/*.js', './test/**/*.ts', './test/**/*.js'] } } }; } config.plugins.push(new ForkTsCheckerWebpackPlugin(forkTsCheckerConfig)); config.plugins.push(new ForkTsCheckerNotifierWebpackPlugin({ title: getModuleName(), skipSuccessful: true, // no notification for successful builds excludeWarnings: true // no notification for warnings })); } if (!devMode) { const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); config.optimization.minimizer = [ // minify css new CssMinimizerPlugin({ test: /\.min\.css$/i, // only minimize required files exclude: /res[\\/]/i, // exclude resources output directory from minimizing as these files are copied parallel: 4, // best ratio between memory consumption and performance on most systems minify: CssMinimizerPlugin.esbuildMinify, minimizerOptions: { logLevel: 'error', // show messages directly to see the details. The message passed to webpack is only an object which is ignored in isWarningIgnored sourcemap: false, // no sourcemaps for css in prod build (needs more heap memory instead) charset: 'utf8', // default is ASCII which requires more escaping. UTF-8 allows for more compact code. target: minimizerTarget } }), // minify js new TerserPlugin({ test: /\.min\.js$/i, // only minimize required files exclude: [/log4javascript-1\.4\.9[\\/]/i, /res[\\/]/i], // exclude resources output directory from minimizing as these files are copied parallel: 4, // best ratio between memory consumption and performance on most systems minify: TerserPlugin.esbuildMinify, terserOptions: { legalComments: 'none', logLevel: 'error', // show messages directly to see the details. The message passed to webpack is only an object which is ignored in isWarningIgnored charset: 'utf8', // default is ASCII which requires more escaping. UTF-8 allows for more compact code. target: minimizerTarget } }) ]; const cyclonedxSkip = ('' + nvl(args.cyclonedxSkip, 'false')) === 'true'; if (!cyclonedxSkip) { /** @type {import('@cyclonedx/webpack-plugin').CycloneDxWebpackPluginOptions} */ const cycloneDxWebpackPluginOptions = { specVersion: nvl(args.cyclonedxVersion, '1.5'), collectEvidence: true, rootComponentType: 'application', validateResults: false, includeWellknown: false }; config.plugins.push(new CycloneDxWebpackPlugin(cycloneDxWebpackPluginOptions)); } } return config; }; /** * Creates a new object that contains the same keys as the given object. The values are replaced with the keys. * So the resulting object looks like: {key1: key1, key2: key2}. */ function toExternals(src, dest) { if (!src) { return; } return Object.keys(src).reduce((obj, current) => { obj[current] = current; return obj; }, dest); } /** * Converts the given base config to a library config meaning that all dependencies declared in the package.json are externalized by default. * * @param {object} config base config to convert to a library config * @param {object} [options] * @param {object} [options.externals] object holding custom externals for the module. See https://webpack.js.org/configuration/externals/ for details about supported formats and types. * @param {boolean} [options.externalizeDevDeps] Add devDependencies as externals. Default is true. * @param {boolean} [options.externalizePeerDeps] Add peerDependencies as externals. Default is true. * @param {boolean} [options.externalizeBundledDeps] Add bundledDependencies as externals. Default is true. * @param {boolean} [options.externalizeOptionalDeps] Add optionalDependencies as externals. Default is true. */ function libraryConfig(config, options = {}) { const packageJson = require(path.resolve('./package.json')); const packageJsonExternals = {}; toExternals(packageJson.dependencies, packageJsonExternals); if (options.externalizeDevDeps ?? true) { toExternals(packageJson.devDependencies, packageJsonExternals); } if (options.externalizePeerDeps ?? true) { toExternals(packageJson.peerDependencies, packageJsonExternals); } if (options.externalizeBundledDeps ?? true) { toExternals(packageJson.bundledDependencies, packageJsonExternals); } if (options.externalizeOptionalDeps ?? true) { toExternals(packageJson.optionalDependencies, packageJsonExternals); } packageJsonExternals.jquery = 'commonjs jquery'; // Make synthetic default import work (import $ from 'jquery') by importing jquery as commonjs module const customExternals = options.externals || {}; const allExternals = {...packageJsonExternals, ...config.externals, ...customExternals}; // Remove CycloneDxWebpackPlugin for libraries as dependencies are marked as externals anyway and are therefore not part of the build. // The SBOM will be created for application level builds only. config.plugins = config.plugins.filter(p => !(p instanceof CycloneDxWebpackPlugin)); // FileList is not necessary in library mode let plugins = config.plugins.map(plugin => { if (plugin instanceof AfterEmitWebpackPlugin) { return new AfterEmitWebpackPlugin({outDir: plugin.options.outDir, createFileList: false}); } return plugin; }); return { ...config, optimization: { ...config.optimization, splitChunks: undefined // disable splitting }, output: { ...config.output, library: { type: 'module' } }, experiments: { // required for library.type = 'module' outputModule: true }, plugins, externals: (context, callback) => markExternals(allExternals, context, callback) }; } function markExternals(allExternals, context, callback) { const request = context.request; if (request.startsWith('.')) { // fast check: continue without externalizing the import for relative paths return callback(); } if (allExternals[request]) { // import matches exactly a declared dependency return callback(null, allExternals[request]); } // check for files in sub-folders of an external for (const [key, value] of Object.entries(allExternals)) { if (request.startsWith(key + '/')) { let result = request; let spacePos = value.indexOf(' '); if (spacePos > 0) { result = value.substring(0, spacePos + 1) + result; } return callback(null, result); } } callback(); // Continue without externalizing the import } /** * @param {object} entry the webpack entry object * @param {object} options the options object to configure which themes should be built and how * @param {[string]} options.themes one or more themes of the availableThemes that should be built. Use 'all' to build all available themes, or 'none' to build no themes. Default is 'all'. * @param {[string]} options.availableThemes the themes that are available. * @param {function} options.generator a function that returns an array containing the key and value for the generated entry. The function will be called for each theme with the theme name as argument. */ function addThemes(entry, options = {}) { let themes = ensureArray(nvl(options.themes, 'all')); let availableThemes = options.availableThemes; if (!availableThemes) { throw 'Please specify the availableThemes'; } let generator = options.generator; if (!generator) { throw 'Please specify a theme entry generator (themeEntryGen) that returns and array containing the key and value of the entry to generate for each theme.'; } if (themes.includes('all')) { themes = availableThemes; } themes = themes.filter(theme => availableThemes.includes(theme)); if (themes.length === 0) { return; } console.log(`Themes: ${themes}`); themes.forEach(theme => { let name = theme === 'default' ? '' : `-${theme}`; let [key, value] = generator(name); entry[key] = value; }); } function computeChunkName(module, chunks, cacheGroupKey) { const entryPointDelim = '~'; const allChunksNames = chunks .map(chunk => chunk.name) .filter(chunkName => !!chunkName) .join(entryPointDelim); let fileName = cacheGroupKey === 'defaultVendors' ? 'vendors' : cacheGroupKey; if (allChunksNames.length < 1) { // there is no chunk name (e.g. lazy loaded module): derive chunk-name from filename const segmentDelim = '-'; if (fileName.length > 0) { fileName += segmentDelim; } return fileName + computeModuleId(module); } if (fileName.length > 0) { fileName += entryPointDelim; } return fileName + allChunksNames; } function computeModuleId(module) { const nodeModules = 'node_modules'; // noinspection JSUnresolvedVariable let id = module.userRequest; const nodeModulesPos = id.lastIndexOf(nodeModules); if (nodeModulesPos < 0) { // use file name id = path.basename(id, '.js'); } else { // use js-module name id = id.substring(nodeModulesPos + nodeModules.length + path.sep.length); let end = id.indexOf(path.sep); if (end >= 0) { if (id.startsWith('@')) { const next = id.indexOf(path.sep, end + 1); if (next >= 0) { end = next; } } id = id.substring(0, end); } } return id.replace(/[/\\\-@:_.|]+/g, '').toLowerCase(); } function ensureArray(array) { if (array === undefined || array === null) { return []; } if (Array.isArray(array)) { return array; } const isIterable = typeof array[Symbol.iterator] === 'function' && typeof array !== 'string'; if (isIterable) { return Array.from(array); } return [array]; } function nvl(arg, defaultValue) { if (arg === undefined || arg === null) { return defaultValue; } return arg; } function isWarningIgnored(devMode, webpackError) { if (devMode || !webpackError) { return false; } // Ignore warnings from esbuild minifier. // One warning is 'Converting "require" to "esm" is currently not supported' which is not of interest. // Others may be created by third party libs which are not of interest as well. return webpackError.name === 'Warning'; } /** * Don't reveal absolute file paths in production mode -> only return the file name relative to its module. * @param info.resourcePath */ function prodDevtoolModuleFilenameTemplate(info) { let path = info.resourcePath || ''; // Search for the last /src/ in the path and return the fragment starting from its parent let result = path.match(/.*\/(.*\/src\/.*)/); if (result) { return result[1]; } // Match everything after the /last node_modules/ in the path result = path.match(/.*\/node_modules\/(.*)/); if (result) { return result[1]; } // Return only the file name (the part after the last /) result = path.match(/([^/\\]*)$/); if (result) { return result[1]; } } function getModuleName() { let packageJsonFile = path.resolve('./package.json'); if (fs.existsSync(packageJsonFile)) { let name = require(packageJsonFile).name; if (name) { return name; } } return path.basename(process.cwd()); } /** * Externalize every import to the main index and replace it with newImport * Keep imports to the excludedFolder. * @param {string} newImport new name of the replaced import, typically the module name * @param {string} excludedFolder imports to that folder won't be replaced * @returns a function that should be added to the webpack externals */ function rewriteIndexImports(newImport, excludedFolder) { // If an import ends by one of these names, it is considered an index import. let indexImports = ['index', 'main/js']; return ({context, request, contextInfo}, callback) => { // Externalize every import to the main index and replace it with newImport // Keep imports pointing to excludedFolder if (isIndexImport(request) && !path.resolve(context, request).includes(excludedFolder)) { return callback(null, newImport); } // Continue without externalizing the import callback(); }; function isIndexImport(path) { for (let imp of indexImports) { if (path.endsWith(imp)) { return true; } } return false; } } function computeTypeCheck(typeCheck, devMode, watchMode) { typeCheck = nvl(typeCheck, 'auto'); if (typeCheck !== 'auto' && typeCheck !== 'fork') { typeCheck = typeCheck.toLowerCase() === 'true'; } if (typeCheck !== 'auto') { return typeCheck; } if (!devMode) { return false; } if (watchMode) { return 'fork'; } return true; } module.exports.addThemes = addThemes; module.exports.libraryConfig = libraryConfig; module.exports.markExternals = markExternals; module.exports.rewriteIndexImports = rewriteIndexImports;