feat: migrate from esbuild to Vite for build configuration across services

This commit is contained in:
Nawaz Dhandala
2026-01-21 11:29:29 +00:00
parent 8fb1a1daf9
commit 64d4c0c6be
15 changed files with 222 additions and 307 deletions

View File

@@ -1,12 +0,0 @@
const { createConfig, build, watch } = require('Common/UI/esbuild-config');
const config = createConfig({
serviceName: 'Accounts',
publicPath: '/accounts/dist/',
});
if (process.argv.includes('--watch')) {
watch(config, 'Accounts');
} else {
build(config, 'Accounts');
}

View File

@@ -7,10 +7,10 @@
"url": "https://github.com/OneUptime/oneuptime"
},
"scripts": {
"dev-build": "NODE_ENV=development node esbuild.config.js",
"dev-build": "NODE_ENV=development npx vite build",
"dev": "npx nodemon",
"build": "NODE_ENV=production node esbuild.config.js",
"analyze": "analyze=true NODE_ENV=production node esbuild.config.js",
"build": "NODE_ENV=production npx vite build",
"analyze": "analyze=true NODE_ENV=production npx vite build",
"test": "",
"compile": "tsc",
"clear-modules": "rm -rf node_modules && rm package-lock.json && npm install",

6
Accounts/vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createViteConfig } from 'Common/UI/vite.config';
export default createViteConfig({
serviceName: 'Accounts',
publicPath: '/accounts/dist/',
});

View File

@@ -1,12 +0,0 @@
const { createConfig, build, watch } = require('Common/UI/esbuild-config');
const config = createConfig({
serviceName: 'AdminDashboard',
publicPath: '/admin/dist/',
});
if (process.argv.includes('--watch')) {
watch(config, 'AdminDashboard');
} else {
build(config, 'AdminDashboard');
}

View File

@@ -15,12 +15,11 @@
"react-router-dom": "^6.30.2"
},
"scripts": {
"dev-build": "NODE_ENV=development node esbuild.config.js",
"dev-build": "NODE_ENV=development npx vite build",
"dev": "npx nodemon",
"build": "NODE_ENV=production node esbuild.config.js",
"analyze": "analyze=true NODE_ENV=production node esbuild.config.js",
"build": "NODE_ENV=production npx vite build",
"analyze": "analyze=true NODE_ENV=production npx vite build",
"test": "react-app-rewired test",
"eject": "echo 'esbuild does not require eject'",
"compile": "tsc",
"clear-modules": "rm -rf node_modules && rm package-lock.json && npm install",
"start": "node --require ts-node/register Serve.ts",

View File

@@ -0,0 +1,6 @@
import { createViteConfig } from 'Common/UI/vite.config';
export default createViteConfig({
serviceName: 'AdminDashboard',
publicPath: '/admin/dist/',
});

View File

@@ -1,245 +0,0 @@
/**
* Shared esbuild configuration factory for OneUptime frontend services
* This creates consistent build configurations across all services
*/
const esbuild = require('esbuild');
const path = require('path');
const fs = require('fs');
const dotenv = require('dotenv');
function createRefractorCompatibilityPlugin() {
const candidateRoots = [
path.resolve(__dirname, '../node_modules/refractor'),
path.resolve(__dirname, '../../node_modules/refractor'),
];
const refractorRoot = candidateRoots.find((packagePath) => fs.existsSync(packagePath));
if (!refractorRoot) {
throw new Error('Unable to locate refractor package for esbuild compatibility plugin.');
}
return {
name: 'refractor-compatibility',
setup(build) {
build.onResolve({ filter: /^refractor\/lib\// }, (args) => {
const relativePath = args.path.replace(/^refractor\/lib\//, '');
const candidatePath = path.join(refractorRoot, 'lib', `${relativePath}.js`);
return { path: candidatePath };
});
build.onResolve({ filter: /^refractor\/lang\// }, (args) => {
const relativePath = args.path.replace(/^refractor\/lang\//, '');
const filename = relativePath.endsWith('.js') ? relativePath : `${relativePath}.js`;
const candidatePath = path.join(refractorRoot, 'lang', filename);
return { path: candidatePath };
});
},
};
}
// CSS Plugin to handle CSS/SCSS files
function createCSSPlugin() {
return {
name: 'css',
setup(build) {
build.onLoad({ filter: /\.s?css$/ }, async (args) => {
const sass = require('sass');
const fs = require('fs');
let contents = fs.readFileSync(args.path, 'utf8');
// Compile SCSS to CSS if it's a SCSS file
if (args.path.endsWith('.scss') || args.path.endsWith('.sass')) {
try {
const result = sass.compile(args.path);
contents = result.css;
} catch (error) {
console.error(`SCSS compilation error in ${args.path}:`, error);
throw error;
}
}
// Return CSS as a string that will be injected into the page
return {
contents: `
const style = document.createElement('style');
style.textContent = ${JSON.stringify(contents)};
document.head.appendChild(style);
`,
loader: 'js',
};
});
},
};
}
// File loader plugin for assets
function createFileLoaderPlugin() {
return {
name: 'file-loader',
setup(build) {
build.onLoad({ filter: /\.(png|jpe?g|gif|svg|woff|woff2|eot|ttf|otf)$/ }, async (args) => {
const fs = require('fs');
const path = require('path');
const contents = fs.readFileSync(args.path);
const filename = path.basename(args.path);
const ext = path.extname(filename);
// For development, we'll use data URLs for simplicity
// In production, you might want to copy files to the output directory
const mimeTypes = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.eot': 'application/vnd.ms-fontobject',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
};
const mimeType = mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
const dataUrl = `data:${mimeType};base64,${contents.toString('base64')}`;
return {
contents: `export default ${JSON.stringify(dataUrl)};`,
loader: 'js',
};
});
},
};
}
// Read environment variables from .env file
function readEnvFile(pathToFile) {
if (!fs.existsSync(pathToFile)) {
console.warn(`Environment file not found: ${pathToFile}`);
return {};
}
const parsed = dotenv.config({ path: pathToFile }).parsed || {};
const env = {};
for (const key in parsed) {
env[`process.env.${key}`] = JSON.stringify(parsed[key]);
}
return env;
}
/**
* Create esbuild configuration for a service
* @param {Object} options - Configuration options
* @param {string} options.serviceName - Name of the service (dashboard, accounts, admin, status-page)
* @param {string} options.publicPath - Public path for assets
* @param {string} [options.entryPoint] - Entry point file (defaults to './src/Index.tsx')
* @param {string} [options.outdir] - Output directory (defaults to './public/dist')
* @param {Object} [options.additionalDefines] - Additional define variables
* @param {Array} [options.additionalExternal] - Additional external modules
* @param {Object} [options.additionalAlias] - Additional aliases
*/
function createConfig(options) {
const {
serviceName,
publicPath,
entryPoint = './src/Index.tsx',
outdir = './public/dist',
additionalDefines = {},
additionalExternal = [],
additionalAlias = {}
} = options;
const isDev = process.env.NODE_ENV !== 'production';
const isAnalyze = process.env.analyze === 'true';
return {
entryPoints: [entryPoint],
bundle: true,
outdir,
format: 'esm', // Changed from 'iife' to 'esm' to support splitting
platform: 'browser',
target: 'es2017',
sourcemap: isDev ? 'inline' : false,
minify: false,
splitting: true, // Now supported with ESM format
publicPath,
define: {
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
...additionalDefines,
},
external: ['react-native-sqlite-storage', ...additionalExternal],
alias: {
'react': path.resolve('./node_modules/react'),
...additionalAlias,
},
plugins: [createRefractorCompatibilityPlugin(), createCSSPlugin(), createFileLoaderPlugin()],
loader: {
'.tsx': 'tsx',
'.ts': 'ts',
'.jsx': 'jsx',
'.js': 'js',
'.json': 'json',
},
resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.json', '.css', '.scss'],
metafile: isAnalyze,
};
}
/**
* Build function that handles the build process
* @param {Object} config - esbuild configuration
* @param {string} serviceName - Name of the service for logging
*/
async function build(config, serviceName) {
const isAnalyze = process.env.analyze === 'true';
try {
const result = await esbuild.build(config);
if (isAnalyze && result.metafile) {
const analyzeText = await esbuild.analyzeMetafile(result.metafile);
console.log(`\n📊 Bundle analysis for ${serviceName}:`);
console.log(analyzeText);
// Write metafile for external analysis tools
const metafilePath = path.join(config.outdir, 'metafile.json');
fs.writeFileSync(metafilePath, JSON.stringify(result.metafile, null, 2));
console.log(`📝 Metafile written to: ${metafilePath}`);
}
console.log(`${serviceName} build completed successfully`);
} catch (error) {
console.error(`${serviceName} build failed:`, error);
process.exit(1);
}
}
/**
* Watch function that handles the watch process
* @param {Object} config - esbuild configuration
* @param {string} serviceName - Name of the service for logging
*/
async function watch(config, serviceName) {
try {
const context = await esbuild.context(config);
await context.watch();
console.log(`👀 Watching ${serviceName} for changes...`);
} catch (error) {
console.error(`${serviceName} watch failed:`, error);
process.exit(1);
}
}
module.exports = {
createConfig,
build,
watch,
createCSSPlugin,
createFileLoaderPlugin,
readEnvFile,
};

184
Common/UI/vite.config.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* Shared Vite configuration factory for OneUptime frontend services
* This creates consistent build configurations across all services with controlled code splitting
*/
import { defineConfig, UserConfig, Plugin } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import fs from 'fs';
interface ServiceConfig {
serviceName: string;
publicPath: string;
outDir?: string;
entryPoint?: string;
}
/**
* Create a Vite plugin for refractor compatibility
* Handles resolving refractor/lib/* and refractor/lang/* imports
*/
function createRefractorCompatibilityPlugin(): Plugin {
const candidateRoots = [
path.resolve(__dirname, '../node_modules/refractor'),
path.resolve(__dirname, '../../node_modules/refractor'),
];
const refractorRoot = candidateRoots.find((packagePath) => fs.existsSync(packagePath));
return {
name: 'refractor-compatibility',
resolveId(source: string) {
if (!refractorRoot) {
return null;
}
if (source.startsWith('refractor/lib/')) {
const relativePath = source.replace(/^refractor\/lib\//, '');
const candidatePath = path.join(refractorRoot, 'lib', `${relativePath}.js`);
if (fs.existsSync(candidatePath)) {
return candidatePath;
}
}
if (source.startsWith('refractor/lang/')) {
const relativePath = source.replace(/^refractor\/lang\//, '');
const filename = relativePath.endsWith('.js') ? relativePath : `${relativePath}.js`;
const candidatePath = path.join(refractorRoot, 'lang', filename);
if (fs.existsSync(candidatePath)) {
return candidatePath;
}
}
return null;
},
};
}
/**
* Create Vite configuration for a service
*/
export function createViteConfig(config: ServiceConfig): UserConfig {
const {
serviceName,
publicPath,
outDir = './public/dist',
entryPoint = './src/Index.tsx'
} = config;
const isDev = process.env.NODE_ENV !== 'production';
const isAnalyze = process.env.analyze === 'true';
return defineConfig({
plugins: [
react(),
createRefractorCompatibilityPlugin(),
],
base: publicPath,
build: {
target: 'es2017',
outDir,
emptyOutDir: true,
sourcemap: isDev ? 'inline' : false,
minify: !isDev,
rollupOptions: {
input: entryPoint,
output: {
entryFileNames: 'Index.js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]',
manualChunks: (id: string) => {
// Vendor chunk: React ecosystem
if (id.includes('node_modules/react/') ||
id.includes('node_modules/react-dom/') ||
id.includes('node_modules/react-router-dom/') ||
id.includes('node_modules/react-router/') ||
id.includes('node_modules/@remix-run/router/')) {
return 'vendor';
}
// UI Components chunk: Common UI library
if (id.includes('Common/UI/Components/') ||
id.includes('Common/UI/Utils/')) {
return 'ui';
}
// Charting libraries chunk
if (id.includes('node_modules/recharts/') ||
id.includes('node_modules/d3-') ||
id.includes('node_modules/victory-')) {
return 'charts';
}
// Monaco editor chunk (large)
if (id.includes('node_modules/@monaco-editor/') ||
id.includes('node_modules/monaco-editor/')) {
return 'monaco';
}
// Flow/diagram libraries
if (id.includes('node_modules/reactflow/') ||
id.includes('node_modules/@reactflow/') ||
id.includes('node_modules/elkjs/')) {
return 'flow';
}
// Syntax highlighting chunk
if (id.includes('node_modules/react-syntax-highlighter/') ||
id.includes('node_modules/refractor/') ||
id.includes('node_modules/prismjs/') ||
id.includes('node_modules/highlight.js/')) {
return 'syntax';
}
// Date/time utilities
if (id.includes('node_modules/moment/') ||
id.includes('node_modules/moment-timezone/')) {
return 'datetime';
}
// Markdown processing
if (id.includes('node_modules/react-markdown/') ||
id.includes('node_modules/remark-') ||
id.includes('node_modules/rehype-') ||
id.includes('node_modules/unified/') ||
id.includes('node_modules/marked/')) {
return 'markdown';
}
// Other large vendor libraries get grouped together
if (id.includes('node_modules/')) {
return 'vendor-misc';
}
// Let Vite handle code splitting for dynamic imports (lazy routes)
return undefined;
},
},
external: ['react-native-sqlite-storage'],
},
// Report compressed sizes in analyze mode
reportCompressedSize: isAnalyze,
},
resolve: {
alias: {
'react': path.resolve('./node_modules/react'),
'Common': path.resolve('../Common'),
},
},
css: {
preprocessorOptions: {
scss: {},
},
},
// Define environment variables
define: {
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
},
// Log level for build output
logLevel: isAnalyze ? 'info' : 'warn',
});
}
export default createViteConfig;

View File

@@ -20,6 +20,8 @@
"author": "OneUptime <hello@oneuptime.com> (https://oneuptime.com/)",
"license": "Apache-2.0",
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"vite": "^5.4.0",
"@faker-js/faker": "^8.0.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.3.0",

View File

@@ -1,12 +0,0 @@
const { createConfig, build, watch } = require('Common/UI/esbuild-config');
const config = createConfig({
serviceName: 'Dashboard',
publicPath: '/dashboard/dist/',
});
if (process.argv.includes('--watch')) {
watch(config, 'Dashboard');
} else {
build(config, 'Dashboard');
}

View File

@@ -8,12 +8,11 @@
},
"scripts": {
"generate-sw": "node scripts/generate-sw.js",
"dev-build": "npm run generate-sw && NODE_ENV=development node esbuild.config.js",
"dev-build": "npm run generate-sw && NODE_ENV=development npx vite build",
"dev": "npx nodemon",
"build": "npm run generate-sw && NODE_ENV=production node esbuild.config.js",
"analyze": "npm run generate-sw && analyze=true NODE_ENV=production node esbuild.config.js",
"build": "npm run generate-sw && NODE_ENV=production npx vite build",
"analyze": "npm run generate-sw && analyze=true NODE_ENV=production npx vite build",
"test": "react-app-rewired test",
"eject": "echo 'esbuild does not require eject'",
"compile": "npm run generate-sw && tsc",
"clear-modules": "rm -rf node_modules && rm package-lock.json && npm install",
"start": "node --require ts-node/register Serve.ts",

6
Dashboard/vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createViteConfig } from 'Common/UI/vite.config';
export default createViteConfig({
serviceName: 'Dashboard',
publicPath: '/dashboard/dist/',
});

View File

@@ -1,12 +0,0 @@
const { createConfig, build, watch } = require('Common/UI/esbuild-config');
const config = createConfig({
serviceName: 'StatusPage',
publicPath: '/status-page/dist/',
});
if (process.argv.includes('--watch')) {
watch(config, 'StatusPage');
} else {
build(config, 'StatusPage');
}

View File

@@ -8,9 +8,9 @@
},
"scripts": {
"dev": "npx nodemon",
"build": "NODE_ENV=production node esbuild.config.js",
"dev-build": "NODE_ENV=development node esbuild.config.js",
"analyze": "analyze=true NODE_ENV=production node esbuild.config.js",
"build": "NODE_ENV=production npx vite build",
"dev-build": "NODE_ENV=development npx vite build",
"analyze": "analyze=true NODE_ENV=production npx vite build",
"test": "",
"compile": "tsc",
"clear-modules": "rm -rf node_modules && rm package-lock.json && npm install",

View File

@@ -0,0 +1,6 @@
import { createViteConfig } from 'Common/UI/vite.config';
export default createViteConfig({
serviceName: 'StatusPage',
publicPath: '/status-page/dist/',
});