UNPKG

@jpapini/webpack-config

Version:
588 lines (575 loc) 17.7 kB
import { logger, colors } from '@jpapini/logger'; import path from 'node:path'; import fs2 from 'node:fs'; import memoizeOne from 'memoize-one'; import { config } from 'dotenv'; import { createRequire } from 'node:module'; import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; import webpack from 'webpack'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; import { RunScriptWebpackPlugin } from 'run-script-webpack-plugin'; import nodeExternals from 'webpack-node-externals'; import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import { mergeWithRules } from 'webpack-merge'; // src/create-build-configuration.ts function _findProjectRoot(currentDir = process.cwd()) { let dir = currentDir; while (dir !== "/") { if (fs2.existsSync(path.join(dir, ".git"))) return dir; dir = path.dirname(dir); } throw new Error(`Project root not found for directory: ${currentDir}`); } var findProjectRoot = memoizeOne(_findProjectRoot); // src/utils/shortern-path.util.ts function shorternPath(absolutePath, currentDir) { const rootDir = findProjectRoot(currentDir); return absolutePath.replace(rootDir, "<root>"); } // src/utils/find-file-path.util.ts function findFilePath(currentDir, filePath) { const resolvedPath = path.join(currentDir, filePath); return fs2.existsSync(resolvedPath) ? resolvedPath : null; } function findFilePathOrThrow(currentDir, filePath) { const resolvedPath = path.join(currentDir, filePath); if (!fs2.existsSync(resolvedPath)) throw new Error(`File not found: ${shorternPath(resolvedPath, currentDir)}`); return resolvedPath; } // src/contexts/base.context.ts var BaseContext = class { _buildType; _id; _projectDir; _rootDir; _outDir; _cacheDir; _pkgJsonFile; _tsconfigFile; _entryFile; _nodeEnv; _envVars; _isProduction; _isWatchMode; _isDevServer; _publicUrl; _useSWC; _swcLoaderConfig; _tsLoaderConfig; constructor(options) { this._buildType = options.buildType; this._id = options.id; this._projectDir = findProjectRoot(options.rootDir); this._rootDir = options.rootDir; this._outDir = path.join(options.rootDir, options.outDir ?? "dist"); this._cacheDir = path.join( options.rootDir, options.cacheDir ?? "node_modules/.cache/webpack" ); this._pkgJsonFile = findFilePathOrThrow(options.rootDir, "package.json"); this._tsconfigFile = findFilePathOrThrow(options.rootDir, "tsconfig.json"); this._entryFile = findFilePathOrThrow(options.rootDir, options.entryFile); this._nodeEnv = options.nodeEnv; this._envVars = options.envVars ?? {}; this._isProduction = options.isProduction; this._isWatchMode = options.isWatchMode; this._isDevServer = options.isDevServer; this._publicUrl = options.publicUrl; this._publicUrl.pathname += this._publicUrl.pathname.endsWith("/") ? "" : "/"; this._useSWC = options.useSWC ?? false; this._swcLoaderConfig = options.swcLoaderConfig ?? {}; this._tsLoaderConfig = options.tsLoaderConfig ?? {}; } get buildType() { return this._buildType; } get id() { return this._id; } get projectDir() { return this._projectDir; } get rootDir() { return this._rootDir; } get outDir() { return this._outDir; } get cacheDir() { return this._cacheDir; } get pkgJsonFile() { return this._pkgJsonFile; } get tsconfigFile() { return this._tsconfigFile; } get entryFile() { return this._entryFile; } get nodeEnv() { return this._nodeEnv; } get envVars() { return this._envVars; } get isProduction() { return this._isProduction; } get isWatchMode() { return this._isWatchMode; } get isDevServer() { return this._isDevServer; } get publicUrl() { return this._publicUrl; } get useSWC() { return this._useSWC; } get swcLoaderConfig() { return this._swcLoaderConfig; } get tsLoaderConfig() { return this._tsLoaderConfig; } print() { logger.info("ID:", colors.blue(this.id)); logger.info("Node environment:", colors.blue(this.nodeEnv)); logger.info("Public URL:", colors.blue(this.publicUrl.toString())); logger.info( "Production build:", this.isProduction ? colors.green("YES") : colors.yellow("NO") ); logger.info( "Watch mode:", this.isWatchMode ? colors.green("ENABLED") : colors.yellow("DISABLED") ); logger.info( "Dev server:", this.isDevServer ? colors.green("ENABLED") : colors.yellow("DISABLED") ); logger.info("Project dir:", colors.blue(this.projectDir)); logger.info("Root dir:", colors.blue(shorternPath(this.rootDir, this.rootDir))); logger.info("Output dir:", colors.blue(shorternPath(this.outDir, this.rootDir))); logger.info("Cache dir:", colors.blue(shorternPath(this.cacheDir, this.rootDir))); logger.info("Manifest file:", colors.blue(shorternPath(this.pkgJsonFile, this.rootDir))); logger.info("TSConfig file:", colors.blue(shorternPath(this.tsconfigFile, this.rootDir))); logger.info("Entry file:", colors.blue(shorternPath(this.entryFile, this.rootDir))); logger.info("Use SWC:", this.useSWC ? colors.green("YES") : colors.yellow("NO")); } }; var NestAppContext = class extends BaseContext { _outFilename; constructor({ outFilename, ...options }) { super(options); this._outFilename = outFilename ?? "main.js"; } get outFilename() { return this._outFilename; } print() { super.print(); logger.info("Output filename:", colors.blue(shorternPath(this.outFilename, this.rootDir))); } }; var ReactAppContext = class extends BaseContext { _htmlTemplateFile; constructor({ htmlTemplateFile, ...options }) { super(options); this._htmlTemplateFile = findFilePath( options.rootDir, htmlTemplateFile ?? "src/index.html" ); } get htmlTemplateFile() { return this._htmlTemplateFile; } print() { super.print(); logger.info( "HTML template file:", this.htmlTemplateFile ? colors.blue(shorternPath(this.htmlTemplateFile, this.rootDir)) : colors.yellow("Not found") ); } }; // src/enums/build-type.enum.ts var BuildType = { REACT_APP: "REACT_APP", NEST_APP: "NEST_APP" }; // src/enums/node-env.enum.ts var NodeEnv = { DEVELOPMENT: "development", PRODUCTION: "production", TEST: "test" }; // src/context.factory.ts function contextFactory(options) { switch (true) { case options.buildType === BuildType.NEST_APP: return new NestAppContext(options); case options.buildType === BuildType.REACT_APP: return new ReactAppContext(options); default: throw new Error("Invalid context options"); } } function loadDotenv(rootDir, dotenvFiles = [".env"]) { const loadedEnvFiles = []; dotenvFiles.map((name) => path.join(rootDir, name)).filter((file) => fs2.existsSync(file)).forEach((file) => { config({ path: file }); loadedEnvFiles.push(file); }); return loadedEnvFiles; } // src/load-env-vars.ts function loadEnvVars(rootDir, env) { const loadedEnvFiles = loadDotenv(rootDir); logger.info("Loading environment variables from:"); if (loadedEnvFiles.length === 0) { logger.log(" -", colors.red("No environment files found")); } else { loadedEnvFiles.forEach((file) => { logger.log(" -", colors.blue(shorternPath(file, rootDir))); }); } process.env.NODE_ENV ??= NodeEnv.PRODUCTION; if (!Object.values(NodeEnv).includes(process.env.NODE_ENV)) { throw new Error( `Invalid NODE_ENV: ${process.env.NODE_ENV}. Valid values are: ${Object.values(NodeEnv).join(", ")}` ); } if (!process.env.PUBLIC_URL) throw new Error("PUBLIC_URL environment variable is required"); let publicUrl; try { publicUrl = new URL(process.env.PUBLIC_URL); } catch { throw new Error(`Invalid PUBLIC_URL: ${process.env.PUBLIC_URL}. Must be a valid URL`); } return { nodeEnv: process.env.NODE_ENV, publicUrl }; } // src/constants.ts var TS_RULE_TEST = /\.(?:ts|tsx|cts|mts)$/iu; var ASSET_RULE_TEST = /\.(?:jpe?g|png|gif|svg)$/iu; var CSS_RULE_TEST = /\.css$/iu; var HASHED_JS_FILENAME_PATTERN = "[name].[contenthash:8].js"; var HASHED_CSS_FILENAME_PATTERN = "[name].[contenthash:8].css"; // src/presets/base.preset.ts var { EnvironmentPlugin, SourceMapDevToolPlugin } = webpack; var require2 = createRequire(import.meta.url); var createBasePreset = (context, config2) => { return { presetName: "base", context: context.rootDir, mode: context.isProduction ? "production" : "development", resolve: { extensions: [".js", ".ts"], extensionAlias: { ".js": [".js", ".ts"], ".cjs": [".cjs", ".cts"], ".mjs": [".mjs", ".mts"] }, plugins: [new TsconfigPathsPlugin({ configFile: context.tsconfigFile })] }, output: { uniqueName: config2.name ?? context.id, devtoolNamespace: config2.name ?? context.id, path: context.outDir, clean: true, chunkFilename: context.isProduction ? HASHED_JS_FILENAME_PATTERN : "[name].js" }, optimization: { minimize: false }, stats: { errorDetails: true }, module: { rules: [ { test: TS_RULE_TEST, exclude: [/dist\//u, /node_modules\//u], use: [ context.useSWC ? { loader: require2.resolve("swc-loader"), options: { ...context.swcLoaderConfig, minify: false, module: { ...context.swcLoaderConfig.module, type: "nodenext" }, jsc: { ...context.swcLoaderConfig.jsc, parser: { ...context.swcLoaderConfig.jsc?.parser, syntax: "typescript" }, transform: { ...context.swcLoaderConfig.jsc?.transform, useDefineForClassFields: true }, keepClassNames: true, externalHelpers: false } } } : { loader: require2.resolve("ts-loader"), options: { ...context.tsLoaderConfig } } ] } ] }, plugins: [ new EnvironmentPlugin({ ...context.envVars, NODE_ENV: context.nodeEnv, PUBLIC_URL: context.publicUrl.toString() }), ...context.isProduction ? [ new SourceMapDevToolPlugin({ namespace: config2.name ?? context.id, filename: "[file].map", noSources: false }), new BundleAnalyzerPlugin({ analyzerMode: "static", generateStatsFile: true, reportFilename: path.join(context.outDir, "analyzer", "report.html"), statsFilename: path.join(context.outDir, "analyzer", "stats.json"), openAnalyzer: false }) ] : [] ].filter(Boolean), cache: !context.isProduction ? { type: "filesystem", cacheDirectory: context.cacheDir } : false }; }; var { HotModuleReplacementPlugin } = webpack; var require3 = createRequire(import.meta.url); var createNestAppPreset = (context) => { if (!(context instanceof NestAppContext)) throw new Error("Invalid context"); return { presetName: "nest-app", devtool: context.isProduction ? false : "inline-source-map", target: "node", node: { __dirname: false, __filename: false }, externalsPresets: { node: true }, externals: [ nodeExternals( context.isWatchMode ? { allowlist: ["webpack/hot/poll?100"] } : void 0 ) ], entry: [...context.isWatchMode ? ["webpack/hot/poll?100"] : [], context.entryFile], output: { filename: context.outFilename }, module: { rules: [ { test: TS_RULE_TEST, use: [ context.useSWC ? { loader: require3.resolve("swc-loader"), options: { jsc: { target: "es2022", parser: { syntax: "typescript", decorators: true, dynamicImport: true }, transform: { legacyDecorator: true, decoratorMetadata: true } } } } : { loader: require3.resolve("ts-loader"), options: {} } ] } ] }, plugins: context.isWatchMode ? [ new HotModuleReplacementPlugin(), new RunScriptWebpackPlugin({ name: context.outFilename, autoRestart: false }) ] : [] }; }; var require4 = createRequire(import.meta.url); var createReactAppPreset = (context) => { if (!(context instanceof ReactAppContext)) throw new Error("Invalid context"); return { presetName: "react-app", devtool: context.isProduction ? false : "eval-source-map", target: "web", node: { __dirname: true, __filename: true }, resolve: { extensions: [".jsx", ".tsx"] }, externalsPresets: { web: true }, optimization: { minimize: context.isProduction, moduleIds: "deterministic", runtimeChunk: "single", splitChunks: { chunks: "all" } }, entry: [context.entryFile], output: { filename: context.isProduction ? HASHED_JS_FILENAME_PATTERN : "[name].js", publicPath: context.publicUrl.toString() }, module: { rules: [ { test: TS_RULE_TEST, use: [ context.useSWC ? { loader: require4.resolve("swc-loader"), options: { minify: context.isProduction, jsc: { target: "es5", parser: { syntax: "typescript", tsx: true }, transform: { react: { runtime: "automatic", refresh: context.isDevServer } } } } } : { loader: require4.resolve("ts-loader"), options: {} } ] }, { test: CSS_RULE_TEST, use: [ MiniCssExtractPlugin.loader, { loader: require4.resolve("css-loader") }, { loader: require4.resolve("postcss-loader"), options: { implementation: require4.resolve("postcss"), postcssOptions: { plugins: [require4.resolve("@tailwindcss/postcss")] } } } ] }, { test: ASSET_RULE_TEST, use: [ { loader: require4.resolve("url-loader") } ] } ] }, plugins: [ new MiniCssExtractPlugin({ filename: context.isProduction ? HASHED_CSS_FILENAME_PATTERN : "[name].css" }), ...context.htmlTemplateFile ? [new HtmlWebpackPlugin({ template: context.htmlTemplateFile })] : [], ...context.isDevServer ? [new ReactRefreshWebpackPlugin({ overlay: { sockIntegration: "whm" } })] : [] ], ...context.isDevServer ? { devServer: { historyApiFallback: true, host: context.publicUrl.hostname || void 0, port: context.publicUrl.port || void 0, hot: true, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization" } } } : {} }; }; var mergeConfig = mergeWithRules({ module: { rules: { test: "match", use: { loader: "match", options: "merge" } } } }); // src/utils/merge-presets.util.ts function mergePresets(context, presets) { const loadedPresets = []; const config2 = presets.reduce((acc, presetFunc) => { const { presetName, ...preset } = presetFunc(context, acc); loadedPresets.push(presetName); return mergeConfig(acc, preset); }, {}); return { config: config2, loadedPresets }; } // src/create-build-configuration.ts function createBuildConfiguration(rootDir, getOptions) { return async function(env) { const { nodeEnv, publicUrl } = loadEnvVars(rootDir); const options = await getOptions(); const contextOptions = { ...options, rootDir, nodeEnv, isProduction: nodeEnv !== NodeEnv.DEVELOPMENT, isDevServer: env?.WEBPACK_SERVE ?? false, isWatchMode: env?.WEBPACK_WATCH ?? false, publicUrl }; const context = contextFactory(contextOptions); context.print(); const { loadedPresets, config: config2 } = mergePresets(context, [ createBasePreset, ...context instanceof NestAppContext ? [createNestAppPreset] : [], ...context instanceof ReactAppContext ? [createReactAppPreset] : [] ]); logger.info("Loaded presets:"); loadedPresets.forEach((presetName) => { logger.log(" -", colors.blue(presetName)); }); return config2; }; } export { BuildType, NodeEnv, createBuildConfiguration, mergeConfig }; //# sourceMappingURL=index.mjs.map //# sourceMappingURL=index.mjs.map