@eclipse-scout/cli
Version:
CLI for Eclipse Scout
661 lines (619 loc) • 24.8 kB
JavaScript
/*
* Copyright (c) 2010, 2025 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 DataObjectTransformer = require('./DataObjectTransformer');
const ModuleNamespaceResolver = require('./ModuleNamespaceResolver');
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');
const ts = require('typescript');
/**
* @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 {object} args.forkTypeCheckOptions a config object to be passed to the ForkTsCheckerWebpackPlugin
* @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
});
}
// browser min. requirements for full ES2022 feature set
// Exception: static initialization blocks (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Static_initialization_blocks) which would require Safari 16
// But static initialization blocks can be transpiled by Babel to static fields which are supported in all these browsers. Therefore, in the code full ES2022 feature set can be used.
const minimizerTarget = ['firefox92', 'chrome93', 'safari15.4'];
const babelOptions = {
compact: false,
cacheDirectory: true,
cacheCompression: false,
presets: [
[require.resolve('@babel/preset-env'), {
debug: false,
targets: {
firefox: '92',
chrome: '93',
safari: '15.4'
}
}]
]
};
const transpileOnly = typeCheck === 'fork' ? true : !typeCheck;
const namespaceResolver = new ModuleNamespaceResolver();
const getCustomTransformers = program => ({
before: [ctx => {
const doTransformer = new DataObjectTransformer(program, ctx, namespaceResolver);
return node => ts.visitNode(node, node => doTransformer.transform(node));
}]
});
getCustomTransformers.namespaceResolver = namespaceResolver; // for setDoTransformerOwnModuleNamespace below
const tsOptions = {
...args.tsOptions,
transpileOnly: transpileOnly,
compilerOptions: {
noEmit: false,
...args.tsOptions?.compilerOptions
},
getCustomTransformers
};
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: /\.[c|m]?tsx?$/,
exclude: /node_modules/,
use: [{
loader: require.resolve('babel-loader'),
options: babelOptions
}, {
loader: require.resolve('ts-loader'),
options: tsOptions
}]
}, {
test: /\.[c|m]?jsx?$/,
use: [{
loader: require.resolve('babel-loader'),
options: babelOptions
}]
}, {
test: /\.[c|m]?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,
...args.forkTypeCheckOptions?.typescript
},
...args.forkTypeCheckOptions
};
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 = {
...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);
}
/**
* Sets the Scout JS module namespace for the root module currently built.
* @param config The build config to modify.
* @param namespace The namespace of this module.
*/
function setDoTransformerOwnModuleNamespace(config, namespace) {
config.module.rules
.flatMap(r => r.use || [])
.find(l => l.loader?.indexOf('ts-loader') >= 0)
.options.getCustomTransformers.namespaceResolver
.ownModuleNamespace = namespace;
}
/**
* 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.setDoTransformerOwnModuleNamespace = setDoTransformerOwnModuleNamespace;
module.exports.rewriteIndexImports = rewriteIndexImports;