const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const CompressionPlugin = require('compression-webpack-plugin'); const zlib = require('zlib'); const sharp = require('sharp'); const webpack = require('webpack'); const packageJson = require('./package.json'); const HOUSE_IMAGE_WIDTH = 260; function envString(...names) { for (const name of names) { const value = process.env[name]; if (typeof value === 'string' && value.trim().length > 0) { return value.trim(); } } return undefined; } function envBoolean(name, fallback = false) { const value = process.env[name]; if (typeof value !== 'string' || value.trim().length === 0) { return fallback; } return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); } module.exports = (env, argv) => { const isProduction = argv.mode === 'production'; const bugsinkEnvironment = envString('FRONTEND_BUGSINK_ENVIRONMENT', 'BUGSINK_ENVIRONMENT', 'SENTRY_ENVIRONMENT') || (isProduction ? 'production' : 'development'); const bugsinkRelease = envString('FRONTEND_BUGSINK_RELEASE', 'BUGSINK_RELEASE', 'SENTRY_RELEASE') || `${packageJson.name}@${packageJson.version}`; return { entry: './src/index.tsx', output: { path: path.resolve(__dirname, 'dist'), filename: isProduction ? '[name].[contenthash:8].js' : 'bundle.js', chunkFilename: isProduction ? '[name].[contenthash:8].js' : '[name].bundle.js', clean: true, publicPath: '/', }, devtool: isProduction ? 'hidden-source-map' : 'eval-cheap-module-source-map', resolve: { extensions: ['.ts', '.tsx', '.js'], }, module: { rules: [ { test: /\.tsx?$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: [ '@babel/preset-env', ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], plugins: isProduction ? [] : ['react-refresh/babel'], }, }, }, { test: /\.css$/, use: [ isProduction ? MiniCssExtractPlugin.loader : 'style-loader', { loader: 'css-loader', options: { url: { filter: (url) => !url.startsWith('/'), }, }, }, 'postcss-loader', ], }, ], }, plugins: [ new webpack.DefinePlugin({ __DEV__: JSON.stringify(!isProduction), __BUGSINK_DSN__: JSON.stringify( envString('FRONTEND_BUGSINK_DSN', 'PUBLIC_BUGSINK_DSN', 'BUGSINK_DSN') || '' ), __BUGSINK_ENVIRONMENT__: JSON.stringify(bugsinkEnvironment), __BUGSINK_RELEASE__: JSON.stringify(bugsinkRelease), __BUGSINK_SEND_DEFAULT_PII__: JSON.stringify( envBoolean('BUGSINK_SEND_DEFAULT_PII', false) ), }), new HtmlWebpackPlugin({ template: './src/index.html', }), new CopyWebpackPlugin({ patterns: [ { from: 'public', noErrorOnMissing: true, globOptions: { ignore: ['**/house.png'], }, }, isProduction ? { from: 'public/house.png', to: 'house.png', noErrorOnMissing: true, transform: { transformer(content) { return sharp(content) .resize({ width: HOUSE_IMAGE_WIDTH, withoutEnlargement: true }) .png({ compressionLevel: 9, palette: true, quality: 85 }) .toBuffer(); }, }, } : { from: 'public/house.png', to: 'house.png', noErrorOnMissing: true, }, ], }), new FaviconsWebpackPlugin({ logo: './public/favicon.svg', favicons: { background: '#0c0a09', icons: { favicons: true, android: false, appleIcon: false, appleStartup: false, yandex: false, windows: false, }, }, }), ...(isProduction ? [ new MiniCssExtractPlugin({ filename: '[name].[contenthash:8].css', chunkFilename: '[name].[contenthash:8].css', }), new CompressionPlugin({ filename: '[path][base].gz', algorithm: 'gzip', test: /\.(js|css|html|svg|json|wasm)$/, threshold: 1024, minRatio: 0.8, }), new CompressionPlugin({ filename: '[path][base].br', algorithm: 'brotliCompress', test: /\.(js|css|html|svg|json|wasm)$/, compressionOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11, }, }, threshold: 1024, minRatio: 0.8, }), ] : [new ReactRefreshWebpackPlugin()]), ], optimization: isProduction ? { minimize: true, minimizer: [ new TerserPlugin({ parallel: true, extractComments: false, terserOptions: { compress: { drop_console: true, drop_debugger: true, passes: 2, }, format: { comments: false, }, keep_classnames: true, keep_fnames: false, }, }), ], splitChunks: { chunks: 'all', cacheGroups: { maplibre: { test: /[\\/]node_modules[\\/]maplibre-gl[\\/]/, name: 'vendor-maplibre', chunks: 'async', priority: 50, enforce: true, reuseExistingChunk: true, }, h3: { test: /[\\/]node_modules[\\/]h3-js[\\/]/, name: 'vendor-h3', chunks: 'async', priority: 45, enforce: true, reuseExistingChunk: true, }, deckMapbox: { test: /[\\/]node_modules[\\/]@deck\.gl[\\/]mapbox[\\/]/, name: 'vendor-deck-mapbox', chunks: 'async', priority: 44, enforce: true, reuseExistingChunk: true, }, deckCore: { test: /[\\/]node_modules[\\/]@deck\.gl[\\/]core[\\/]/, name: 'vendor-deck-core', chunks: 'async', priority: 43, enforce: true, reuseExistingChunk: true, }, deckLayers: { test: /[\\/]node_modules[\\/]@deck\.gl[\\/](?:layers|geo-layers)[\\/]/, name: 'vendor-deck-layers', chunks: 'async', priority: 42, enforce: true, reuseExistingChunk: true, }, deck: { test: /[\\/]node_modules[\\/]@deck\.gl[\\/]/, name: 'vendor-deck', chunks: 'async', priority: 40, enforce: true, reuseExistingChunk: true, }, luma: { test: /[\\/]node_modules[\\/]@luma\.gl[\\/]/, name: 'vendor-luma', chunks: 'async', priority: 35, enforce: true, reuseExistingChunk: true, }, webgpuSupport: { test: /[\\/]node_modules[\\/]wgsl_reflect[\\/]/, name: 'vendor-webgpu-support', chunks: 'async', priority: 34, enforce: true, reuseExistingChunk: true, }, mapData: { test: /[\\/]node_modules[\\/](?:@mapbox[\\/]tiny-sdf|@protomaps[\\/]basemaps|earcut|supercluster)[\\/]/, name: 'vendor-map-data', chunks: 'async', priority: 33, enforce: true, reuseExistingChunk: true, }, mapSupport: { test: /[\\/]node_modules[\\/](?:@loaders\.gl|@math\.gl|@probe\.gl|@vis\.gl|mjolnir\.js|react-map-gl)[\\/]/, name: 'vendor-map-support', chunks: 'async', priority: 30, enforce: true, reuseExistingChunk: true, }, joyride: { test: /[\\/]node_modules[\\/](?:react-joyride|deepmerge|scroll|scrollparent|react-innertext)[\\/]/, name: 'vendor-joyride', chunks: 'async', priority: 25, enforce: true, reuseExistingChunk: true, }, }, }, } : undefined, devServer: { host: '0.0.0.0', port: 3001, allowedHosts: 'all', client: { webSocketURL: 'auto://0.0.0.0:0/ws', }, historyApiFallback: { index: '/index.html', }, hot: true, liveReload: true, static: { directory: path.resolve(__dirname, 'public'), watch: { ignored: ['**/assets/**'], }, }, proxy: [ { context: ['/api', '/s'], target: process.env.API_PROXY_TARGET || 'http://localhost:8001', }, { context: ['/pb'], target: process.env.PB_PROXY_TARGET || 'http://localhost:8090', pathRewrite: { '^/pb': '' }, }, ], }, }; };