502 lines
19 KiB
JavaScript
502 lines
19 KiB
JavaScript
const url = require('url');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
|
const webpack = require('webpack');
|
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
|
|
|
|
const proxyTarget = process.env.MLFLOW_PROXY;
|
|
const useProxyServer = !!proxyTarget && !process.env.MLFLOW_DEV_PROXY_MODE;
|
|
|
|
const isDevserverWebsocketRequest = (request) =>
|
|
request.url === '/ws' && (request.headers.upgrade === 'websocket' || request.headers['sec-websocket-version']);
|
|
|
|
function mayProxy(pathname) {
|
|
const publicPrefixPrefix = '/static-files/';
|
|
if (pathname.startsWith(publicPrefixPrefix)) {
|
|
const maybePublicPath = path.resolve('public', pathname.substring(publicPrefixPrefix.length));
|
|
return !fs.existsSync(maybePublicPath);
|
|
} else {
|
|
const maybePublicPath = path.resolve('public', pathname.slice(1));
|
|
return !fs.existsSync(maybePublicPath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For 303's we need to rewrite the location to have host of `localhost`.
|
|
*/
|
|
function rewriteRedirect(proxyRes, req) {
|
|
if (proxyRes.headers['location'] && proxyRes.statusCode === 303) {
|
|
var u = url.parse(proxyRes.headers['location']);
|
|
u.host = req.headers['host'];
|
|
proxyRes.headers['location'] = u.format();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In Databricks, we send a cookie with a CSRF token and set the path of the cookie as "/mlflow".
|
|
* We need to rewrite the path to "/" for the dev index.html/bundle.js to use the CSRF token.
|
|
*/
|
|
function rewriteCookies(proxyRes) {
|
|
if (proxyRes.headers['set-cookie'] !== undefined) {
|
|
const newCookies = [];
|
|
proxyRes.headers['set-cookie'].forEach((c) => {
|
|
newCookies.push(c.replace('Path=/mlflow', 'Path=/'));
|
|
});
|
|
proxyRes.headers['set-cookie'] = newCookies;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Since the base publicPath is configured to a relative path ("static-files/"),
|
|
* the files referenced inside CSS files (e.g. fonts) can be incorrectly resolved
|
|
* (e.g. /path/to/css/file/static-files/static/path/to/font.woff). We need to override
|
|
* the CSS loader to make sure it will resolve to a proper relative path. This is
|
|
* required for the production (bundled) builds only.
|
|
*/
|
|
function configureIframeCSSPublicPaths(config, env) {
|
|
// eslint-disable-next-line prefer-const
|
|
let shouldFixCSSPaths = env === 'production';
|
|
|
|
if (!shouldFixCSSPaths) {
|
|
return config;
|
|
}
|
|
|
|
let cssRuleFixed = false;
|
|
config.module.rules
|
|
.filter((rule) => rule.oneOf instanceof Array)
|
|
.forEach((rule) => {
|
|
rule.oneOf
|
|
.filter((oneOf) => oneOf.test?.toString() === /\.css$/.toString())
|
|
.forEach((cssRule) => {
|
|
cssRule.use
|
|
?.filter((loaderConfig) => loaderConfig?.loader.match(/[\/\\]mini-css-extract-plugin[\/\\]/))
|
|
.forEach((loaderConfig) => {
|
|
loaderConfig.options = { publicPath: '../../' };
|
|
cssRuleFixed = true;
|
|
});
|
|
});
|
|
});
|
|
|
|
if (!cssRuleFixed) {
|
|
throw new Error('Failed to fix CSS paths!');
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* pdfjs-dist publishes browser-ready ESM (.mjs) bundles that do not need any
|
|
* further transformation. Two webpack defaults rewrite them anyway, both of
|
|
* which bake the build machine's filesystem path into the emitted assets and
|
|
* break the PDF artifact viewer at runtime:
|
|
*
|
|
* 1. CRA's babel-loader rule transforms .mjs files in node_modules.
|
|
* @babel/plugin-transform-runtime injects helper imports, and inside the
|
|
* webpack worker child compilation those resolve to fully-qualified
|
|
* paths instead of module specifiers.
|
|
* → Skip babel-loader for pdfjs-dist.
|
|
*
|
|
* 2. pdfjs uses `import.meta.url` inside Node-only code paths
|
|
* (NodeCanvasFactory). Webpack's default is to substitute it with the
|
|
* source file's absolute `file://` URL, which leaks the build machine
|
|
* path. The previous fix set `parser.javascript.importMeta = false`,
|
|
* but that switch tells webpack to leave the token alone, shipping a
|
|
* literal `import.meta.url` into a classic-script chunk and triggering
|
|
* `SyntaxError: Cannot use 'import.meta' outside a module` in the
|
|
* browser (mlflow/mlflow#23720).
|
|
* → Strip `import.meta.url` to `""` before webpack parses the file.
|
|
*/
|
|
function preservePdfjsBundles(config) {
|
|
const pdfjsPattern = /[\\/]node_modules[\\/]pdfjs-dist[\\/]/;
|
|
const isBabelLoaderRule = (r) => {
|
|
if (typeof r.loader === 'string' && r.loader.includes('babel-loader')) {
|
|
return true;
|
|
}
|
|
if (Array.isArray(r.use) && r.use.some((u) => typeof u?.loader === 'string' && u.loader.includes('babel-loader'))) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
let touched = false;
|
|
config.module.rules.forEach((rule) => {
|
|
if (!Array.isArray(rule.oneOf)) return;
|
|
rule.oneOf.forEach((r) => {
|
|
if (isBabelLoaderRule(r)) {
|
|
const exclude = Array.isArray(r.exclude) ? r.exclude : r.exclude ? [r.exclude] : [];
|
|
r.exclude = [...exclude, pdfjsPattern];
|
|
touched = true;
|
|
}
|
|
});
|
|
});
|
|
if (!touched) {
|
|
throw new Error('Failed to exclude pdfjs-dist from babel-loader: no rule matched');
|
|
}
|
|
config.module.rules.push({
|
|
test: pdfjsPattern,
|
|
enforce: 'pre',
|
|
use: [require.resolve('./PdfjsStripImportMetaLoader')],
|
|
});
|
|
return config;
|
|
}
|
|
|
|
function enableOptionalTypescript(config) {
|
|
/**
|
|
* Essential TS config is already inside CRA's config - the only
|
|
* missing thing is resolved extensions.
|
|
*/
|
|
config.resolve.extensions.push('.ts', '.tsx');
|
|
|
|
/**
|
|
* We're going to exclude typechecking test files from webpack's pipeline
|
|
*/
|
|
|
|
const ForkTsCheckerPlugin = config.plugins.find((plugin) => plugin.constructor.name === 'ForkTsCheckerWebpackPlugin');
|
|
|
|
if (ForkTsCheckerPlugin) {
|
|
ForkTsCheckerPlugin.options.typescript.configOverwrite.exclude = [
|
|
'**/*.test.ts',
|
|
'**/*.test.tsx',
|
|
'**/*.stories.tsx',
|
|
].map((pattern) => path.join(__dirname, 'src', pattern));
|
|
} else {
|
|
throw new Error('Failed to setup Typescript');
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
function i18nOverrides(config) {
|
|
// https://github.com/webpack/webpack/issues/11467#issuecomment-691873586
|
|
config.module.rules.push({
|
|
test: /\.m?js/,
|
|
resolve: {
|
|
fullySpecified: false,
|
|
},
|
|
});
|
|
config.module.rules = config.module.rules.map((rule) => {
|
|
if (rule.oneOf instanceof Array) {
|
|
return {
|
|
...rule,
|
|
oneOf: [
|
|
{
|
|
test: [new RegExp(path.join('src/i18n/', '.*json'))],
|
|
use: [
|
|
{
|
|
loader: require.resolve('./I18nCompileLoader'),
|
|
},
|
|
],
|
|
},
|
|
...rule.oneOf,
|
|
],
|
|
};
|
|
}
|
|
|
|
return rule;
|
|
});
|
|
|
|
return config;
|
|
}
|
|
|
|
module.exports = function () {
|
|
const config = {
|
|
babel: {
|
|
env: {
|
|
test: {
|
|
plugins: [
|
|
[
|
|
require.resolve('babel-plugin-formatjs'),
|
|
{
|
|
idInterpolationPattern: '[sha512:contenthash:base64:6]',
|
|
removeDefaultMessage: false,
|
|
},
|
|
],
|
|
],
|
|
},
|
|
},
|
|
presets: [
|
|
[
|
|
'@babel/preset-react',
|
|
{
|
|
runtime: 'automatic',
|
|
importSource: '@emotion/react',
|
|
},
|
|
],
|
|
],
|
|
plugins: [
|
|
[
|
|
require.resolve('babel-plugin-formatjs'),
|
|
{
|
|
idInterpolationPattern: '[sha512:contenthash:base64:6]',
|
|
},
|
|
],
|
|
[
|
|
require.resolve('@emotion/babel-plugin'),
|
|
{
|
|
sourceMap: false,
|
|
},
|
|
],
|
|
],
|
|
},
|
|
devServer: {
|
|
...(useProxyServer && {
|
|
hot: true,
|
|
https: true,
|
|
proxy: [
|
|
// Heads up src/setupProxy.js is indirectly referenced by CRA
|
|
// and also defines proxies.
|
|
{
|
|
context: function (pathname, request) {
|
|
// Dev server's WS calls should not be proxied
|
|
if (isDevserverWebsocketRequest(request)) {
|
|
return false;
|
|
}
|
|
return mayProxy(pathname);
|
|
},
|
|
target: proxyTarget,
|
|
secure: false,
|
|
changeOrigin: true,
|
|
ws: true,
|
|
xfwd: true,
|
|
onProxyRes: (proxyRes, req) => {
|
|
rewriteRedirect(proxyRes, req);
|
|
rewriteCookies(proxyRes);
|
|
},
|
|
},
|
|
],
|
|
host: 'localhost',
|
|
port: process.env.PORT || 3000,
|
|
open: false,
|
|
}),
|
|
client: {
|
|
overlay: {
|
|
errors: false,
|
|
warnings: false,
|
|
runtimeErrors: (error) => {
|
|
// It is safe to ignore based on https://stackoverflow.com/a/50387233/12110203.
|
|
if (error?.message.match(/ResizeObserver/i)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
jest: {
|
|
configure: (jestConfig) => {
|
|
/*
|
|
* Jest running on the currently used node version is not yet capable of ESM processing:
|
|
* https://jestjs.io/docs/ecmascript-modules
|
|
* https://nodejs.org/api/vm.html#vm_class_vm_module
|
|
*
|
|
* Since there are certain ESM-built dependencies used in MLFLow, we need
|
|
* to add a few exceptions to the standard ignore pattern for babel.
|
|
*/
|
|
const createIgnorePatternForESM = () => {
|
|
// List all the modules that we *want* to be transpiled by babel
|
|
const transpileModules = [
|
|
'@databricks/design-system',
|
|
'@babel/runtime/.+?/esm',
|
|
'@ant-design/icons',
|
|
'@ant-design/icons-svg',
|
|
];
|
|
|
|
// We'll ignore only dependencies in 'node_modules' directly within certain
|
|
// directories in order to avoid false positive matches in nested modules.
|
|
const validNodeModulesRoots = ['mlflow/web/js'];
|
|
|
|
// prettier-ignore
|
|
return `(${validNodeModulesRoots.join('|')})\\/node_modules\\/((?!(${transpileModules.join('|')})).)+(js|jsx|mjs|cjs|ts|tsx|json)$`;
|
|
};
|
|
|
|
jestConfig.resetMocks = false; // ML-20462 Restore resetMocks
|
|
jestConfig.collectCoverageFrom = ['src/**/*.{js,jsx}', '!**/*.test.{js,jsx}', '!**/__tests__/*.{js,jsx}'];
|
|
jestConfig.coverageReporters = ['lcov'];
|
|
jestConfig.setupFiles = ['jest-canvas-mock'];
|
|
jestConfig.setupFilesAfterEnv.push('<rootDir>/scripts/env-mocks.js');
|
|
jestConfig.setupFilesAfterEnv.push('<rootDir>/scripts/setup-jest-dom-matchers.js');
|
|
jestConfig.setupFilesAfterEnv.push('<rootDir>/scripts/setup-testing-library.js');
|
|
jestConfig.setupFilesAfterEnv.push('<rootDir>/src/setupTests.js');
|
|
// Adjust config to work with dependencies using ".mjs" file extensions
|
|
jestConfig.moduleFileExtensions.push('mjs');
|
|
// Remove when this issue is resolved: https://github.com/gsoft-inc/craco/issues/393
|
|
jestConfig.transform = {
|
|
'\\.[jt]sx?$': ['babel-jest', { configFile: './jest.babel.config.js' }],
|
|
...jestConfig.transform,
|
|
};
|
|
jestConfig.transformIgnorePatterns = ['\\.pnp\\.[^\\/]+$', createIgnorePatternForESM()];
|
|
jestConfig.globalSetup = '<rootDir>/scripts/global-setup.js';
|
|
|
|
const moduleNameMapper = {
|
|
...jestConfig.moduleNameMapper,
|
|
// bugfix for ESM issue in remark, see: https://github.com/orgs/remarkjs/discussions/1247
|
|
'unist-util-visit-parents/do-not-use-color': '<rootDir>/node_modules/unist-util-visit-parents/lib/color.js',
|
|
'vfile/do-not-use-conditional-minpath': '<rootDir>/node_modules/vfile/lib/minpath.browser.js',
|
|
'vfile/do-not-use-conditional-minproc': '<rootDir>/node_modules/vfile/lib/minproc.browser.js',
|
|
'vfile/do-not-use-conditional-minurl': '<rootDir>/node_modules/vfile/lib/minurl.browser.js',
|
|
// other aliases
|
|
'@databricks/i18n': '<rootDir>/src/i18n/i18n',
|
|
'@databricks/design-system/(.+)': '<rootDir>/node_modules/@databricks/design-system/dist/$1',
|
|
'@databricks/web-shared/(.*)': '<rootDir>/src/shared/web-shared/$1',
|
|
'@mlflow/mlflow/(.*)': '<rootDir>/$1',
|
|
// mock files for recharts components
|
|
'^recharts$': '<rootDir>/__mocks__/recharts.tsx',
|
|
};
|
|
|
|
jestConfig.moduleNameMapper = moduleNameMapper;
|
|
|
|
return jestConfig;
|
|
},
|
|
},
|
|
webpack: {
|
|
configure: (webpackConfig, { env }) => {
|
|
webpackConfig.output.publicPath = 'static-files/';
|
|
// monaco-editor ships vendored CSS (e.g. the hover widget's hover.css) that uses
|
|
// `justify-content: end`, which autoprefixer flags as "end value has mixed support".
|
|
// It is third-party CSS we cannot edit, and CRA's production build treats webpack
|
|
// warnings as errors under CI. Scope the suppression to monaco-editor so the same
|
|
// warning still surfaces for our own CSS.
|
|
webpackConfig.ignoreWarnings = [
|
|
...(webpackConfig.ignoreWarnings || []),
|
|
(warning) => {
|
|
const message = warning?.message ?? '';
|
|
const resource = warning?.module?.resource ?? '';
|
|
return /autoprefixer:.*mixed support/.test(message) && resource.includes('monaco-editor');
|
|
},
|
|
];
|
|
webpackConfig = i18nOverrides(webpackConfig);
|
|
webpackConfig = configureIframeCSSPublicPaths(webpackConfig, env);
|
|
webpackConfig = enableOptionalTypescript(webpackConfig);
|
|
webpackConfig = preservePdfjsBundles(webpackConfig);
|
|
webpackConfig.resolve = {
|
|
...webpackConfig.resolve,
|
|
plugins: [new TsconfigPathsPlugin(), ...webpackConfig.resolve.plugins],
|
|
fallback: {
|
|
// Required by 'plotly.js' download image feature
|
|
stream: require.resolve('stream-browserify'),
|
|
},
|
|
alias: {
|
|
...webpackConfig.resolve.alias,
|
|
// Fix integration with react 18 and react-dnd@15
|
|
// https://github.com/react-dnd/react-dnd/issues/3433#issuecomment-1102144912
|
|
'react/jsx-runtime.js': require.resolve('react/jsx-runtime'),
|
|
'react/jsx-dev-runtime.js': require.resolve('react/jsx-dev-runtime'),
|
|
},
|
|
};
|
|
|
|
// Add separate entry for notebook renderer
|
|
webpackConfig.entry = {
|
|
main: webpackConfig.entry,
|
|
'ml-model-trace-renderer': path.resolve(
|
|
__dirname,
|
|
'src/shared/web-shared/model-trace-explorer/oss-notebook-renderer/index.ts',
|
|
),
|
|
'telemetry-worker': path.resolve(__dirname, 'src/telemetry/worker/TelemetryLogger.worker.ts'),
|
|
};
|
|
|
|
// Configure output for multiple entries
|
|
webpackConfig.output = {
|
|
...webpackConfig.output,
|
|
filename: (pathData) => {
|
|
if (pathData.chunk.name === 'telemetry-worker') {
|
|
// serve SharedWorker file at top-level, it seems to be more
|
|
// stable than if it's contained in `static/js/...`. previously
|
|
// i was running into issues with webpack path resolution
|
|
return 'TelemetryLogger.[name].[contenthash].worker.js';
|
|
}
|
|
return pathData.chunk.name === 'ml-model-trace-renderer'
|
|
? 'lib/notebook-trace-renderer/js/[name].[contenthash].js'
|
|
: 'static/js/[name].[contenthash:8].js';
|
|
},
|
|
chunkFilename: (pathData) => {
|
|
if (pathData.chunk.name === 'telemetry-worker') {
|
|
return 'TelemetryLogger.[name].[contenthash].worker.chunk.js';
|
|
}
|
|
return pathData.chunk.name?.includes('ml-model-trace-renderer')
|
|
? 'lib/notebook-trace-renderer/js/[name].[contenthash].chunk.js'
|
|
: 'static/js/[name].[contenthash:8].chunk.js';
|
|
},
|
|
};
|
|
|
|
// Configure CSS extraction for notebook renderer
|
|
if (env === 'production') {
|
|
const MiniCssExtractPlugin = webpackConfig.plugins.find(
|
|
(plugin) => plugin.constructor.name === 'MiniCssExtractPlugin',
|
|
);
|
|
|
|
if (MiniCssExtractPlugin) {
|
|
MiniCssExtractPlugin.options = {
|
|
...MiniCssExtractPlugin.options,
|
|
filename: (pathData) => {
|
|
return pathData.chunk.name === 'ml-model-trace-renderer'
|
|
? 'lib/notebook-trace-renderer/css/[name].[contenthash].css'
|
|
: 'static/css/[name].[contenthash:8].css';
|
|
},
|
|
chunkFilename: (pathData) => {
|
|
return pathData.chunk.name?.includes('ml-model-trace-renderer')
|
|
? 'lib/notebook-trace-renderer/css/[name].[contenthash].chunk.css'
|
|
: 'static/css/[name].[contenthash:8].chunk.css';
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// Configure main HtmlWebpackPlugin to exclude notebook renderer chunks
|
|
const mainHtmlPlugin = webpackConfig.plugins.find((plugin) => plugin.constructor.name === 'HtmlWebpackPlugin');
|
|
if (mainHtmlPlugin) {
|
|
mainHtmlPlugin.options.excludeChunks = ['ml-model-trace-renderer', 'telemetry-worker'];
|
|
}
|
|
|
|
// Add HTML template for notebook renderer
|
|
webpackConfig.plugins.push(
|
|
new HtmlWebpackPlugin({
|
|
template: path.resolve(
|
|
__dirname,
|
|
'src/shared/web-shared/model-trace-explorer/oss-notebook-renderer/index.html',
|
|
),
|
|
filename: 'lib/notebook-trace-renderer/index.html',
|
|
chunks: ['ml-model-trace-renderer'],
|
|
inject: true,
|
|
publicPath: '/static-files/',
|
|
minify:
|
|
env === 'production'
|
|
? {
|
|
removeComments: true,
|
|
collapseWhitespace: true,
|
|
removeRedundantAttributes: true,
|
|
useShortDoctype: true,
|
|
removeEmptyAttributes: true,
|
|
removeStyleLinkTypeAttributes: true,
|
|
keepClosingSlash: true,
|
|
minifyJS: true,
|
|
minifyCSS: true,
|
|
minifyURLs: true,
|
|
}
|
|
: false,
|
|
base: '/',
|
|
}),
|
|
);
|
|
|
|
console.log('Webpack config:', webpackConfig);
|
|
return webpackConfig;
|
|
},
|
|
plugins: [
|
|
new webpack.EnvironmentPlugin({
|
|
MLFLOW_SHOW_GDPR_PURGING_MESSAGES: process.env.MLFLOW_SHOW_GDPR_PURGING_MESSAGES ? 'true' : 'false',
|
|
MLFLOW_USE_ABSOLUTE_AJAX_URLS: process.env.MLFLOW_USE_ABSOLUTE_AJAX_URLS ? 'true' : 'false',
|
|
}),
|
|
// Only the dataset record editor uses Monaco today, and only for JSON. Restricting
|
|
// languages + dropping the search/quickCommand features keeps the lazy chunk to ~1MB
|
|
// gz instead of the full ~3MB.
|
|
new MonacoWebpackPlugin({
|
|
languages: ['json'],
|
|
features: ['!gotoSymbol', '!documentSymbols'],
|
|
filename: 'static/js/monaco-[name].worker.[contenthash:8].js',
|
|
}),
|
|
],
|
|
},
|
|
};
|
|
return config;
|
|
};
|