@jpapini/webpack-config
Version:
Custom Webpack configuration for bundling projects.
588 lines (575 loc) • 17.7 kB
JavaScript
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