the browser-facing portion of osu!
at master 8.8 kB view raw
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2// See the LICENCE file in the repository root for full licence text. 3 4'use strict'; 5 6// built-in imports 7const { spawnSync } = require('child_process'); 8const fs = require('fs'); 9const path = require('path'); 10 11const Autoprefixer = require('autoprefixer'); 12const CopyPlugin = require('copy-webpack-plugin'); 13const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 14const dotenv = require('dotenv'); 15const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 16const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 17const TerserPlugin = require('terser-webpack-plugin'); 18const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 19const Watchpack = require('watchpack'); 20const webpack = require('webpack'); 21const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); 22const generateLocalizations = require('./resources/js/cli/generate-localizations'); 23const modNamesGenerator = require('./resources/js/cli/mod-names-generator'); 24 25 26// #region env 27const env = process.env.NODE_ENV || 'development'; 28dotenv.config({ path: `.env.${env}` }); 29dotenv.config(); 30 31const inProduction = env === 'production' || process.argv.includes('-p'); 32 33const writeManifest = !(process.env.SKIP_MANIFEST === '1' 34 || process.env.SKIP_MANIFEST === 'true' 35 || process.env.SKIP_MANIFEST); 36// #endregion 37 38// #region helpers 39// Most plugins should follow webpack's own interpolation format: 40// https://github.com/webpack/loader-utils#interpolatename 41function outputFilename(name, ext = '[ext]', hashType = 'contenthash:8') { 42 return `${name}.[${hashType}]${ext}`; 43} 44 45function resolvePath(...segments) { 46 return path.resolve(__dirname, ...segments); 47} 48 49// #endregion 50 51// #region entrypoints and output 52const entry = {}; 53const entrypointDirs = [ 54 'resources/css/entrypoints', 55 'resources/js/entrypoints', 56]; 57const supportedExts = new Set(['.coffee', '.less', '.ts', '.tsx']); 58for (const entrypointsPath of entrypointDirs) { 59 fs.readdirSync(resolvePath(entrypointsPath), { withFileTypes: true }).forEach((item) => { 60 if (item.isFile()) { 61 const filename = item.name; 62 const ext = path.extname(filename); 63 64 if (supportedExts.has(ext)) { 65 const entryName = path.basename(filename, ext); 66 67 if (entry[entryName] == null) { 68 entry[entryName] = []; 69 } 70 entry[entryName].push(resolvePath(entrypointsPath, filename)); 71 } 72 } 73 }); 74} 75 76const output = { 77 filename: outputFilename('js/[name]', '.js'), 78 path: resolvePath('public/assets'), 79 publicPath: '/assets/', 80}; 81 82// #endregion 83 84// #region plugin list 85const plugins = [ 86 new ForkTsCheckerWebpackPlugin(), 87 new webpack.ProvidePlugin({ 88 $: 'jquery', 89 _: 'lodash', 90 d3: 'd3', // TODO: d3 is fat and probably should have it's own chunk 91 jQuery: 'jquery', 92 moment: 'moment', 93 React: 'react', 94 ReactDOM: 'react-dom', 95 }), 96 new webpack.DefinePlugin({ 97 docsUrl: JSON.stringify(process.env.DOCS_URL ?? 'https://docs.ppy.sh'), 98 }), 99 new webpack.IgnorePlugin({ 100 // don't add moment locales to bundle. 101 contextRegExp: /moment$/, 102 resourceRegExp: /^\.\/locale$/, 103 }), 104 new MiniCssExtractPlugin({ 105 filename: outputFilename('css/[name]', '.css'), 106 }), 107 new CopyPlugin({ 108 patterns: [ 109 { from: 'resources/builds/locales', to: outputFilename('js/locales/[name]') }, 110 { from: 'node_modules/moment/locale', to: outputFilename('js/moment-locales/[name]') }, 111 { from: 'node_modules/@discordapp/twemoji/dist/svg/1f1??-1f1??.svg', to: 'images/flags/[name][ext]' }, 112 ], 113 }), 114]; 115 116if (writeManifest) { 117 plugins.push(new WebpackManifestPlugin({ 118 filter: (file) => file.path.match(/^\/assets\/(?:css|js)\/.*\.(?:css|js)$/) !== null, 119 map: (file) => { 120 const baseDir = file.path.match(/^\/assets\/(css|js)\//)?.[1]; 121 if (baseDir !== null && !file.name.startsWith(`${baseDir}/`)) { 122 file.name = `${baseDir}/${file.name}`; 123 } 124 125 return file; 126 }, 127 })); 128} 129 130// TODO: should have a different flag for this 131if (!inProduction) { 132 const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 133 plugins.push(new CleanWebpackPlugin()); 134 135 const notifierConfigPath = resolvePath('.webpack-build-notifier-config.js'); 136 if (fs.existsSync(notifierConfigPath)) { 137 const WebpackBuildNotifierPlugin = require('webpack-build-notifier'); 138 plugins.push(new WebpackBuildNotifierPlugin(require(notifierConfigPath))); 139 } 140} 141 142// #endregion 143 144// #region Loader rules 145const rules = [ 146 { 147 exclude: /node_modules/, 148 loader: 'ts-loader', 149 options: { 150 transpileOnly: true, 151 }, 152 test: /\.tsx?$/, 153 }, 154 { 155 test: /\.coffee$/, 156 use: ['coffee-loader'], 157 }, 158 { 159 test: /\.less$/, 160 use: [ 161 MiniCssExtractPlugin.loader, 162 { 163 loader: 'css-loader', 164 options: { 165 importLoaders: 1, 166 sourceMap: true, 167 }, 168 }, 169 { 170 loader: 'postcss-loader', 171 options: { 172 postcssOptions: { 173 plugins: [Autoprefixer], 174 }, 175 }, 176 }, 177 { loader: 'less-loader', options: { sourceMap: true } }, 178 ], 179 }, 180 { 181 generator: { 182 filename: outputFilename('images/[name]'), 183 }, 184 test: /(\.(png|jpe?g|gif|webp)$|^((?!font).)*\.svg$)/, 185 type: 'asset/resource', 186 }, 187 { 188 generator: { 189 filename: outputFilename('fonts/[name]'), 190 }, 191 test: /(\.(woff2?|ttf|eot|otf)$|font.*\.svg$)/, 192 type: 'asset/resource', 193 }, 194]; 195 196// #endregion 197 198// #region resolvers 199const resolve = { 200 alias: { 201 '@fonts': path.resolve(__dirname, 'resources/fonts'), 202 '@images': path.resolve(__dirname, 'public/images'), 203 }, 204 extensions: ['*', '.js', '.coffee', '.ts', '.tsx'], 205 modules: [ 206 resolvePath('resources/builds'), 207 resolvePath('resources/js'), 208 'node_modules', 209 ], 210 plugins: [new TsconfigPathsPlugin()], 211}; 212 213// #endregion 214 215// #region optimization and chunk splitting settings 216function partialPathCheck(pathCheck, partialPathArray) { 217 return pathCheck.includes(['', ...partialPathArray, ''].join(path.sep)); 218} 219 220const docsOnlyLibraries = [ 221 ['node_modules', 'highlight.js'], 222 ['node_modules', 'jets'], 223]; 224 225const cacheGroups = { 226 commons: { 227 chunks: 'initial', 228 minChunks: 2, 229 name: 'commons', 230 priority: -20, 231 }, 232 vendor: { 233 chunks: 'initial', 234 name: 'vendor', 235 priority: -10, 236 reuseExistingChunk: true, 237 // Doing it this way doesn't split the css imported via app.less from the main css bundle. 238 test: (module) => module.resource && ( 239 partialPathCheck(module.resource, ['node_modules']) 240 && docsOnlyLibraries.every((p) => !partialPathCheck(module.resource, p)) 241 ), 242 }, 243}; 244 245const optimization = { 246 moduleIds: 'deterministic', 247 runtimeChunk: { 248 name: 'runtime', 249 }, 250 splitChunks: { 251 cacheGroups, 252 }, 253}; 254 255if (inProduction) { 256 optimization.minimizer = [ 257 new TerserPlugin({ 258 terserOptions: { 259 safari10: true, 260 sourceMap: true, 261 }, 262 }), 263 new CssMinimizerPlugin(), 264 ]; 265} 266 267// #endregion 268 269const watches = [ 270 { 271 callback: modNamesGenerator, 272 path: resolvePath('database/mods.json'), 273 type: 'file', 274 }, 275 { 276 callback: () => spawnSync( 277 'php', 278 ['artisan', 'ziggy:generate', 'resources/builds/ziggy.js', '--types'], 279 { stdio: 'inherit' }, 280 ), 281 path: resolvePath('routes/web.php'), 282 type: 'file', 283 }, 284 { 285 callback: generateLocalizations, 286 path: resolvePath('resources/lang'), 287 type: 'dir', 288 }, 289]; 290 291function configFunction(_env, argv) { 292 watches.forEach((watched) => watched.callback()); 293 294 if (argv.watch) { 295 const wp = new Watchpack({ 296 // fire an aggregated event after 200ms on changes. 297 aggregateTimeout: 200, 298 // same as webpack-cli's handling 299 poll: argv.watchOptionsPoll, 300 }); 301 302 wp.watch({ 303 directories: watches.filter((x) => x.type === 'dir').map((x) => x.path), 304 files: watches.filter((x) => x.type === 'file').map((x) => x.path), 305 }); 306 307 wp.on('aggregated', (changes, removals) => { 308 watches.forEach((watched) => { 309 if (changes.has(watched.path) || removals.has(watched.path)) { 310 watched.callback(); 311 } 312 }); 313 }); 314 } 315 316 return { 317 devtool: 'source-map', 318 entry, 319 mode: inProduction ? 'production' : 'development', 320 module: { 321 rules, 322 }, 323 optimization, 324 output, 325 plugins, 326 resolve, 327 stats: { 328 entrypoints: false, 329 errorDetails: false, 330 excludeAssets: [ 331 // exclude copied files 332 /^js\/(moment-locales|locales)\//, 333 /^fonts/, 334 /^images/, 335 ], 336 }, 337 }; 338} 339 340module.exports = configFunction;