// eslint-disable-next-line import/order const crypto = require('./helpers/patched_crypto'); const fs = require('fs'); const path = require('path'); const BABEL_VERSION = require('@babel/core/package.json').version; const SOURCEGRAPH_VERSION = require('@sourcegraph/code-host-integration/package.json').version; const BABEL_LOADER_VERSION = require('babel-loader/package.json').version; const CompressionPlugin = require('compression-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const glob = require('glob'); const VueLoaderPlugin = require('vue-loader/lib/plugin'); const VUE_LOADER_VERSION = require('vue-loader/package.json').version; const VUE_VERSION = require('vue/package.json').version; const webpack = require('webpack'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const { StatsWriterPlugin } = require('webpack-stats-plugin'); const WEBPACK_VERSION = require('webpack/package.json').version; const createIncrementalWebpackCompiler = require('./helpers/incremental_webpack_compiler'); const IS_EE = require('./helpers/is_ee_env'); const IS_JH = require('./helpers/is_jh_env'); const vendorDllHash = require('./helpers/vendor_dll_hash'); const MonacoWebpackPlugin = require('./plugins/monaco_webpack'); const GraphqlKnownOperationsPlugin = require('./plugins/graphql_known_operations_plugin'); const ROOT_PATH = path.resolve(__dirname, '..'); const SUPPORTED_BROWSERS = fs.readFileSync(path.join(ROOT_PATH, '.browserslistrc'), 'utf-8'); const SUPPORTED_BROWSERS_HASH = crypto .createHash('sha256') .update(SUPPORTED_BROWSERS) .digest('hex'); const VENDOR_DLL = process.env.WEBPACK_VENDOR_DLL && process.env.WEBPACK_VENDOR_DLL !== 'false'; const CACHE_PATH = process.env.WEBPACK_CACHE_PATH || path.join(ROOT_PATH, 'tmp/cache'); const IS_PRODUCTION = process.env.NODE_ENV === 'production'; const IS_DEV_SERVER = process.env.WEBPACK_SERVE === 'true'; const { DEV_SERVER_HOST, DEV_SERVER_PUBLIC_ADDR } = process.env; const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10); const DEV_SERVER_ALLOWED_HOSTS = process.env.DEV_SERVER_ALLOWED_HOSTS && process.env.DEV_SERVER_ALLOWED_HOSTS.split(','); const DEV_SERVER_LIVERELOAD = IS_DEV_SERVER && process.env.DEV_SERVER_LIVERELOAD !== 'false'; const INCREMENTAL_COMPILER_ENABLED = IS_DEV_SERVER && process.env.DEV_SERVER_INCREMENTAL && process.env.DEV_SERVER_INCREMENTAL !== 'false'; const INCREMENTAL_COMPILER_TTL = Number(process.env.DEV_SERVER_INCREMENTAL_TTL) || Infinity; const INCREMENTAL_COMPILER_RECORD_HISTORY = IS_DEV_SERVER && !process.env.CI; const WEBPACK_REPORT = process.env.WEBPACK_REPORT && process.env.WEBPACK_REPORT !== 'false'; const WEBPACK_MEMORY_TEST = process.env.WEBPACK_MEMORY_TEST && process.env.WEBPACK_MEMORY_TEST !== 'false'; const NO_COMPRESSION = process.env.NO_COMPRESSION && process.env.NO_COMPRESSION !== 'false'; const NO_SOURCEMAPS = process.env.NO_SOURCEMAPS && process.env.NO_SOURCEMAPS !== 'false'; const WEBPACK_OUTPUT_PATH = path.join(ROOT_PATH, 'public/assets/webpack'); const WEBPACK_PUBLIC_PATH = '/assets/webpack/'; const SOURCEGRAPH_PACKAGE = '@sourcegraph/code-host-integration'; const SOURCEGRAPH_PATH = path.join('sourcegraph', SOURCEGRAPH_VERSION, '/'); const SOURCEGRAPH_OUTPUT_PATH = path.join(WEBPACK_OUTPUT_PATH, SOURCEGRAPH_PATH); const SOURCEGRAPH_PUBLIC_PATH = path.join(WEBPACK_PUBLIC_PATH, SOURCEGRAPH_PATH); const devtool = IS_PRODUCTION ? 'source-map' : 'cheap-module-eval-source-map'; let autoEntriesCount = 0; let watchAutoEntries = []; const defaultEntries = ['./main']; const incrementalCompiler = createIncrementalWebpackCompiler( INCREMENTAL_COMPILER_RECORD_HISTORY, INCREMENTAL_COMPILER_ENABLED, path.join(CACHE_PATH, 'incremental-webpack-compiler-history.json'), INCREMENTAL_COMPILER_TTL, ); function generateEntries() { // generate automatic entry points const autoEntries = {}; const autoEntriesMap = {}; const pageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'app/assets/javascripts'), }); watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')]; function generateAutoEntries(entryPath, prefix = '.') { const chunkPath = entryPath.replace(/\/index\.js$/, ''); const chunkName = chunkPath.replace(/\//g, '.'); autoEntriesMap[chunkName] = `${prefix}/${entryPath}`; } pageEntries.forEach((entryPath) => generateAutoEntries(entryPath)); if (IS_EE) { const eePageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'ee/app/assets/javascripts'), }); eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'ee')); watchAutoEntries.push(path.join(ROOT_PATH, 'ee/app/assets/javascripts/pages/')); } if (IS_JH) { const eePageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), }); eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'jh')); watchAutoEntries.push(path.join(ROOT_PATH, 'jh/app/assets/javascripts/pages/')); } const autoEntryKeys = Object.keys(autoEntriesMap); autoEntriesCount = autoEntryKeys.length; // import ancestor entrypoints within their children autoEntryKeys.forEach((entry) => { const entryPaths = [autoEntriesMap[entry]]; const segments = entry.split('.'); while (segments.pop()) { const ancestor = segments.join('.'); if (autoEntryKeys.includes(ancestor)) { entryPaths.unshift(autoEntriesMap[ancestor]); } } autoEntries[entry] = defaultEntries.concat(entryPaths); }); /* If you create manual entries, ensure that these import `app/assets/javascripts/webpack.js` right at the top of the entry in order to ensure that the public path is correctly determined for loading assets async. See: https://webpack.js.org/configuration/output/#outputpublicpath Note: WebPack 5 has an 'auto' option for the public path which could allow us to remove this option Note 2: If you are using web-workers, you might need to reset the public path, see: https://gitlab.com/gitlab-org/gitlab/-/issues/321656 */ const manualEntries = { default: defaultEntries, sentry: './sentry/index.js', performance_bar: './performance_bar/index.js', jira_connect_app: './jira_connect/subscriptions/index.js', sandboxed_mermaid: './lib/mermaid.js', redirect_listbox: './entrypoints/behaviors/redirect_listbox.js', }; return Object.assign(manualEntries, incrementalCompiler.filterEntryPoints(autoEntries)); } const alias = { // Map Apollo client to apollo/client/core to prevent react related imports from being loaded '@apollo/client$': '@apollo/client/core', '~': path.join(ROOT_PATH, 'app/assets/javascripts'), emojis: path.join(ROOT_PATH, 'fixtures/emojis'), empty_states: path.join(ROOT_PATH, 'app/views/shared/empty_states'), icons: path.join(ROOT_PATH, 'app/views/shared/icons'), images: path.join(ROOT_PATH, 'app/assets/images'), vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'), jquery$: 'jquery/dist/jquery.slim.js', jest: path.join(ROOT_PATH, 'spec/frontend'), shared_queries: path.join(ROOT_PATH, 'app/graphql/queries'), // the following resolves files which are different between CE and EE ee_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'), // the following resolves files which are different between CE and JH jh_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'), // the following resolves files which are different between CE/EE/JH any_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'), // override loader path for icons.svg so we do not duplicate this asset '@gitlab/svgs/dist/icons.svg': path.join( ROOT_PATH, 'app/assets/javascripts/lib/utils/icons_path.js', ), }; if (IS_EE) { Object.assign(alias, { ee: path.join(ROOT_PATH, 'ee/app/assets/javascripts'), ee_component: path.join(ROOT_PATH, 'ee/app/assets/javascripts'), ee_empty_states: path.join(ROOT_PATH, 'ee/app/views/shared/empty_states'), ee_icons: path.join(ROOT_PATH, 'ee/app/views/shared/icons'), ee_images: path.join(ROOT_PATH, 'ee/app/assets/images'), ee_jest: path.join(ROOT_PATH, 'ee/spec/frontend'), ee_else_ce: path.join(ROOT_PATH, 'ee/app/assets/javascripts'), jh_else_ee: path.join(ROOT_PATH, 'ee/app/assets/javascripts'), any_else_ce: path.join(ROOT_PATH, 'ee/app/assets/javascripts'), }); } if (IS_JH) { Object.assign(alias, { jh: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), jh_component: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), jh_empty_states: path.join(ROOT_PATH, 'jh/app/views/shared/empty_states'), jh_icons: path.join(ROOT_PATH, 'jh/app/views/shared/icons'), jh_images: path.join(ROOT_PATH, 'jh/app/assets/images'), jh_jest: path.join(ROOT_PATH, 'jh/spec/frontend'), // jh path alias https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74305#note_732793956 jh_else_ce: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), jh_else_ee: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), any_else_ce: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), }); } if (!IS_PRODUCTION) { const fixtureDir = IS_EE ? 'fixtures-ee' : 'fixtures'; Object.assign(alias, { test_fixtures: path.join(ROOT_PATH, `tmp/tests/frontend/${fixtureDir}`), test_fixtures_static: path.join(ROOT_PATH, 'spec/frontend/fixtures/static'), test_helpers: path.join(ROOT_PATH, 'spec/frontend_integration/test_helpers'), }); } let dll; if (VENDOR_DLL && !IS_PRODUCTION) { const dllHash = vendorDllHash(); const dllCachePath = path.join(ROOT_PATH, `tmp/cache/webpack-dlls/${dllHash}`); dll = { manifestPath: path.join(dllCachePath, 'vendor.dll.manifest.json'), cacheFrom: dllCachePath, cacheTo: path.join(WEBPACK_OUTPUT_PATH, `dll.${dllHash}/`), publicPath: `dll.${dllHash}/vendor.dll.bundle.js`, exists: null, }; } module.exports = { mode: IS_PRODUCTION ? 'production' : 'development', context: path.join(ROOT_PATH, 'app/assets/javascripts'), entry: generateEntries, output: { path: WEBPACK_OUTPUT_PATH, publicPath: WEBPACK_PUBLIC_PATH, filename: IS_PRODUCTION ? '[name].[contenthash:8].bundle.js' : '[name].bundle.js', chunkFilename: IS_PRODUCTION ? '[name].[contenthash:8].chunk.js' : '[name].chunk.js', globalObject: 'this', // allow HMR and web workers to play nice }, resolve: { extensions: ['.js'], alias, }, module: { strictExportPresence: true, rules: [ { type: 'javascript/auto', test: /\.mjs$/, use: [], }, { test: /\.js$/, exclude: (modulePath) => /node_modules|vendor[\\/]assets/.test(modulePath) && !/\.vue\.js/.test(modulePath), loader: 'babel-loader', options: { cacheDirectory: path.join(CACHE_PATH, 'babel-loader'), cacheIdentifier: [ process.env.BABEL_ENV || process.env.NODE_ENV || 'development', webpack.version, BABEL_VERSION, BABEL_LOADER_VERSION, // Ensure that changing supported browsers will refresh the cache // in order to not pull in outdated files that import core-js SUPPORTED_BROWSERS_HASH, ].join('|'), cacheCompression: false, }, }, { test: /\.vue$/, loader: 'vue-loader', options: { cacheDirectory: path.join(CACHE_PATH, 'vue-loader'), cacheIdentifier: [ process.env.NODE_ENV || 'development', webpack.version, VUE_VERSION, VUE_LOADER_VERSION, ].join('|'), }, }, { test: /\.(graphql|gql)$/, exclude: /node_modules/, loader: 'graphql-tag/loader', }, { test: /icons\.svg$/, loader: 'file-loader', options: { name: '[name].[contenthash:8].[ext]', }, }, { test: /\.svg$/, exclude: /icons\.svg$/, loader: 'raw-loader', }, { test: /\.(gif|png|mp4)$/, loader: 'url-loader', options: { limit: 2048 }, }, { test: /_worker\.js$/, use: [ { loader: 'worker-loader', options: { name: '[name].[contenthash:8].worker.js', inline: IS_DEV_SERVER, }, }, 'babel-loader', ], }, { test: /\.(worker(\.min)?\.js|pdf|bmpr)$/, exclude: /node_modules/, loader: 'file-loader', options: { name: '[name].[contenthash:8].[ext]', }, }, { test: /.css$/, use: [ 'vue-style-loader', { loader: 'css-loader', options: { modules: 'global', localIdentName: '[name].[contenthash:8].[ext]', }, }, ], }, { test: /\.(eot|ttf|woff|woff2)$/, include: /node_modules\/(katex\/dist\/fonts|monaco-editor)/, loader: 'file-loader', options: { name: '[name].[contenthash:8].[ext]', esModule: false, }, }, { test: /editor\/schema\/.+\.json$/, type: 'javascript/auto', loader: 'file-loader', options: { name: '[name].[contenthash:8].[ext]', }, }, ], }, optimization: { // Replace 'hashed' with 'deterministic' in webpack 5 moduleIds: 'hashed', runtimeChunk: 'single', splitChunks: { maxInitialRequests: 20, // In order to prevent firewalls tripping up: https://gitlab.com/gitlab-org/gitlab/-/issues/22648 automaticNameDelimiter: '-', cacheGroups: { default: false, common: () => ({ priority: 20, name: 'main', chunks: 'initial', minChunks: autoEntriesCount * 0.9, }), prosemirror: { priority: 17, name: 'prosemirror', chunks: 'all', test: /[\\/]node_modules[\\/]prosemirror.*?[\\/]/, minChunks: 2, reuseExistingChunk: true, }, graphql: { priority: 16, name: 'graphql', chunks: 'all', test: /[\\/]node_modules[\\/][^\\/]*(immer|apollo|graphql|zen-observable)[^\\/]*[\\/]/, minChunks: 2, reuseExistingChunk: true, }, monaco: { priority: 15, name: 'monaco', chunks: 'all', test: /[\\/]node_modules[\\/]monaco-editor[\\/]/, minChunks: 2, reuseExistingChunk: true, }, echarts: { priority: 14, name: 'echarts', chunks: 'all', test: /[\\/]node_modules[\\/](echarts|zrender)[\\/]/, minChunks: 2, reuseExistingChunk: true, }, security_reports: { priority: 13, name: 'security_reports', chunks: 'initial', test: /[\\/](vue_shared[\\/](security_reports|license_compliance)|security_dashboard)[\\/]/, minChunks: 2, reuseExistingChunk: true, }, vendors: { priority: 10, chunks: 'async', test: /[\\/](node_modules|vendor[\\/]assets[\\/]javascripts)[\\/]/, }, commons: { chunks: 'all', minChunks: 2, reuseExistingChunk: true, }, }, }, }, plugins: [ // manifest filename must match config.webpack.manifest_filename // webpack-rails only needs assetsByChunkName to function properly new StatsWriterPlugin({ filename: 'manifest.json', transform(data, opts) { const stats = opts.compiler.getStats().toJson({ chunkModules: false, source: false, chunks: false, modules: false, assets: true, errors: !IS_PRODUCTION, warnings: !IS_PRODUCTION, }); // tell our rails helper where to find the DLL files if (dll) { stats.dllAssets = dll.publicPath; } return JSON.stringify(stats, null, 2); }, }), // enable vue-loader to use existing loader rules for other module types new VueLoaderPlugin(), // automatically configure monaco editor web workers new MonacoWebpackPlugin({ globalAPI: true, }), new GraphqlKnownOperationsPlugin({ filename: 'graphql_known_operations.yml' }), // fix legacy jQuery plugins which depend on globals new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery', }), // if DLLs are enabled, detect whether the DLL exists and create it automatically if necessary dll && { apply(compiler) { compiler.hooks.beforeCompile.tapAsync('DllAutoCompilePlugin', (params, callback) => { if (dll.exists) { callback(); } else if (fs.existsSync(dll.manifestPath)) { console.log(`Using vendor DLL found at: ${dll.cacheFrom}`); dll.exists = true; callback(); } else { console.log( `Warning: No vendor DLL found at: ${dll.cacheFrom}. Compiling DLL automatically.`, ); // eslint-disable-next-line global-require const dllConfig = require('./webpack.vendor.config'); const dllCompiler = webpack(dllConfig); dllCompiler.run((err, stats) => { if (err) { return callback(err); } const info = stats.toJson(); if (stats.hasErrors()) { console.error(info.errors.join('\n\n')); return callback('DLL not compiled successfully.'); } if (stats.hasWarnings()) { console.warn(info.warnings.join('\n\n')); console.warn('DLL compiled with warnings.'); } else { console.log('DLL compiled successfully.'); } dll.exists = true; return callback(); }); } }); }, }, // reference our compiled DLL modules dll && new webpack.DllReferencePlugin({ context: ROOT_PATH, manifest: dll.manifestPath, }), dll && new CopyWebpackPlugin({ patterns: [ { from: dll.cacheFrom, to: dll.cacheTo, }, ], }), !IS_EE && new webpack.NormalModuleReplacementPlugin(/^ee_component\/(.*)\.vue/, (resource) => { // eslint-disable-next-line no-param-reassign resource.request = path.join( ROOT_PATH, 'app/assets/javascripts/vue_shared/components/empty_component.js', ); }), !IS_JH && new webpack.NormalModuleReplacementPlugin(/^jh_component\/(.*)\.vue/, (resource) => { // eslint-disable-next-line no-param-reassign resource.request = path.join( ROOT_PATH, 'app/assets/javascripts/vue_shared/components/empty_component.js', ); }), new CopyWebpackPlugin({ patterns: [ { from: path.join(ROOT_PATH, 'node_modules/pdfjs-dist/cmaps/'), to: path.join(WEBPACK_OUTPUT_PATH, 'cmaps/'), }, { from: path.join(ROOT_PATH, 'node_modules', SOURCEGRAPH_PACKAGE, '/'), to: SOURCEGRAPH_OUTPUT_PATH, globOptions: { ignore: ['package.json'], }, }, { from: path.join( ROOT_PATH, 'node_modules/@gitlab/visual-review-tools/dist/visual_review_toolbar.js', ), to: WEBPACK_OUTPUT_PATH, }, ], }), // compression can require a lot of compute time and is disabled in CI IS_PRODUCTION && !NO_COMPRESSION && new CompressionPlugin(), // WatchForChangesPlugin // TODO: publish this as a separate plugin IS_DEV_SERVER && { apply(compiler) { compiler.hooks.emit.tapAsync('WatchForChangesPlugin', (compilation, callback) => { const missingDeps = Array.from(compilation.missingDependencies); const nodeModulesPath = path.join(ROOT_PATH, 'node_modules'); const hasMissingNodeModules = missingDeps.some( (file) => file.indexOf(nodeModulesPath) !== -1, ); // watch for changes to missing node_modules if (hasMissingNodeModules) compilation.contextDependencies.add(nodeModulesPath); // watch for changes to automatic entrypoints watchAutoEntries.forEach((watchPath) => compilation.contextDependencies.add(watchPath)); // report our auto-generated bundle count if (incrementalCompiler.enabled) { incrementalCompiler.logStatus(autoEntriesCount); } else { console.log( `${autoEntriesCount} entries from '/pages' automatically added to webpack output.`, ); } callback(); }); }, }, // output the in-memory heap size upon compilation and exit WEBPACK_MEMORY_TEST && { apply(compiler) { compiler.hooks.emit.tapAsync('ReportMemoryConsumptionPlugin', () => { console.log('Assets compiled...'); if (global.gc) { console.log('Running garbage collection...'); global.gc(); } else { console.error( "WARNING: you must use the --expose-gc node option to accurately measure webpack's heap size", ); } const memoryUsage = process.memoryUsage().heapUsed; const toMB = (bytes) => Math.floor(bytes / 1024 / 1024); console.log(`Webpack heap size: ${toMB(memoryUsage)} MB`); const webpackStatistics = { memoryUsage, date: Date.now(), // milliseconds commitSHA: process.env.CI_COMMIT_SHA, nodeVersion: process.versions.node, webpackVersion: WEBPACK_VERSION, }; console.log(webpackStatistics); fs.writeFileSync( path.join(ROOT_PATH, 'webpack-dev-server.json'), JSON.stringify(webpackStatistics), ); // exit in case we're running webpack-dev-server if (IS_DEV_SERVER) { process.exit(); } }); }, }, // optionally generate webpack bundle analysis WEBPACK_REPORT && new BundleAnalyzerPlugin({ analyzerMode: 'static', generateStatsFile: true, openAnalyzer: false, reportFilename: path.join(ROOT_PATH, 'webpack-report/index.html'), statsFilename: path.join(ROOT_PATH, 'webpack-report/stats.json'), statsOptions: { source: false, }, }), new webpack.DefinePlugin({ // These are used to define window.gon.ee, window.gon.jh and other things properly in tests: 'process.env.IS_EE': JSON.stringify(IS_EE), 'process.env.IS_JH': JSON.stringify(IS_JH), // These are used to check against "EE" properly in application code IS_EE: IS_EE ? 'window.gon && window.gon.ee' : JSON.stringify(false), IS_JH: IS_JH ? 'window.gon && window.gon.jh' : JSON.stringify(false), // This is used by Sourcegraph because these assets are loaded dnamically 'process.env.SOURCEGRAPH_PUBLIC_PATH': JSON.stringify(SOURCEGRAPH_PUBLIC_PATH), }), /* Pikaday has a optional dependency to moment. We are currently not utilizing moment. Ignoring this import removes warning from our development build. Upstream reference: https://github.com/Pikaday/Pikaday/blob/5c1a7559be/pikaday.js#L14 */ new webpack.IgnorePlugin(/moment/, /pikaday/), ].filter(Boolean), devServer: { setupMiddlewares: (middlewares, devServer) => { if (!devServer) { throw new Error('webpack-dev-server is not defined'); } const incrementalCompilerMiddleware = incrementalCompiler.createMiddleware(devServer); if (incrementalCompilerMiddleware) { middlewares.unshift(incrementalCompilerMiddleware); } return middlewares; }, // Only print errors to CLI devMiddleware: { stats: 'errors-only', }, host: DEV_SERVER_HOST || 'localhost', port: DEV_SERVER_PORT || 3808, // Setting up hot module reloading // HMR works by setting up a websocket server and injecting // a client script which connects to that server. // The server will push messages to the client to reload parts // of the JavaScript or reload the page if necessary webSocketServer: DEV_SERVER_LIVERELOAD ? 'ws' : false, hot: DEV_SERVER_LIVERELOAD, liveReload: DEV_SERVER_LIVERELOAD, // The following settings are mainly needed for HMR support in gitpod. // Per default only local hosts are allowed, but here we could // allow different hosts (e.g. ['.gitpod'], all of gitpod), // as the webpack server will run on a different subdomain than // the rails application ...(DEV_SERVER_ALLOWED_HOSTS ? { allowedHosts: DEV_SERVER_ALLOWED_HOSTS } : {}), client: { ...(DEV_SERVER_PUBLIC_ADDR ? { webSocketURL: DEV_SERVER_PUBLIC_ADDR } : {}), }, }, devtool: NO_SOURCEMAPS ? false : devtool, node: { fs: 'empty', // sqljs requires fs setImmediate: false, }, };