xref: /openbmc/webui-vue/vite.config.js (revision e5b9cca7a85b49ae95d629765529b872d6a4d57e)
1/// <reference types="vitest" />
2import { defineConfig, loadEnv } from 'vite';
3import vue from '@vitejs/plugin-vue';
4import basicSsl from '@vitejs/plugin-basic-ssl';
5import svgLoader from 'vite-svg-loader';
6import viteCompression from 'vite-plugin-compression';
7import { fileURLToPath, URL } from 'node:url';
8import path from 'node:path';
9import fs from 'node:fs';
10
11// Plugin to resolve directory imports to index.js (like Webpack does)
12function resolveDirectoryIndex() {
13  return {
14    name: 'resolve-directory-index',
15    resolveId(source, importer) {
16      if (!importer || source.startsWith('\0')) return null;
17
18      // Resolve the full path
19      let resolved;
20      if (source.startsWith('@/')) {
21        resolved = path.resolve(__dirname, 'src', source.slice(2));
22      } else if (source.startsWith('./') || source.startsWith('../')) {
23        resolved = path.resolve(path.dirname(importer), source);
24      } else {
25        return null;
26      }
27
28      // Check if it's a directory with an index.js
29      try {
30        const stats = fs.statSync(resolved);
31        if (stats.isDirectory()) {
32          const indexPath = path.join(resolved, 'index.js');
33          if (fs.existsSync(indexPath)) {
34            return indexPath;
35          }
36        }
37      } catch {
38        // Path doesn't exist, let Vite handle it
39      }
40
41      return null;
42    },
43  };
44}
45
46export default defineConfig(({ mode }) => {
47  // Load env file based on `mode` in the current working directory.
48  const env = loadEnv(mode, process.cwd(), '');
49
50  const envName = env.VITE_ENV_NAME;
51  const hasCustomStyles = env.CUSTOM_STYLES === 'true';
52  const hasCustomStore = env.CUSTOM_STORE === 'true';
53  const hasCustomRouter = env.CUSTOM_ROUTER === 'true';
54  const hasCustomAppNav = env.CUSTOM_APP_NAV === 'true';
55
56  // Build SCSS additionalData for prepending imports
57  const scssAdditionalData = (() => {
58    if (hasCustomStyles && envName !== undefined) {
59      return `
60        @import "@/assets/styles/bmc/helpers";
61        @import "@/env/assets/styles/_${envName}";
62        @import "@/assets/styles/bootstrap/_helpers";
63      `;
64    } else {
65      return `
66        @import "@/assets/styles/bmc/helpers";
67        @import "@/assets/styles/bootstrap/_helpers";
68      `;
69    }
70  })();
71
72  // Build custom aliases for environment-specific overrides
73  const customAliases = {};
74  if (envName !== undefined) {
75    if (hasCustomStore) {
76      // If env has custom store, resolve all store modules
77      customAliases['./store'] = path.resolve(
78        __dirname,
79        `src/env/store/${envName}.js`,
80      );
81      customAliases['../store'] = path.resolve(
82        __dirname,
83        `src/env/store/${envName}.js`,
84      );
85    }
86    if (hasCustomRouter) {
87      // If env has custom router, resolve routes
88      customAliases['./routes'] = path.resolve(
89        __dirname,
90        `src/env/router/${envName}.js`,
91      );
92    }
93    if (hasCustomAppNav) {
94      // If env has custom AppNavigation
95      customAliases['./AppNavigationMixin'] = path.resolve(
96        __dirname,
97        `src/env/components/AppNavigation/${envName}.js`,
98      );
99    }
100  }
101
102  // Helper to inject auth token from cookie
103  const injectAuthToken = (proxyReq, req) => {
104    const cookies = req.headers.cookie;
105    if (cookies) {
106      const match = cookies.match(/X-Auth-Token=([^;]+)/);
107      if (match) {
108        proxyReq.setHeader('X-Auth-Token', match[1]);
109      }
110    }
111  };
112
113  // Helper to remove HSTS header
114  const removeHsts = (proxyRes) => {
115    delete proxyRes.headers['strict-transport-security'];
116  };
117
118  // Check if HTTPS should be enabled (default: true)
119  const useHttps = env.DEV_HTTPS !== 'false';
120
121  return {
122    plugins: [
123      resolveDirectoryIndex(),
124      vue(),
125      svgLoader({
126        defaultImport: 'component',
127      }),
128      // Enable HTTPS with auto-generated self-signed certificate
129      ...(useHttps ? [basicSsl()] : []),
130      // Compression for production builds
131      ...(mode === 'production'
132        ? [
133            viteCompression({
134              deleteOriginFile: true,
135              algorithm: 'gzip',
136            }),
137          ]
138        : []),
139    ],
140
141    resolve: {
142      alias: {
143        '@': fileURLToPath(new URL('./src', import.meta.url)),
144        ...customAliases,
145      },
146      // Allow importing without extensions (like Vue CLI did)
147      extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
148    },
149
150    css: {
151      preprocessorOptions: {
152        scss: {
153          additionalData: scssAdditionalData,
154          // Silence Sass deprecation warnings from Bootstrap and other dependencies.
155          // Bootstrap is working on a long-term fix for Dart Sass compatibility.
156          // See: https://getbootstrap.com/docs/5.3/customize/sass/#importing
157          silenceDeprecations: ['import'],
158          quietDeps: true,
159        },
160        sass: {
161          additionalData: scssAdditionalData,
162          silenceDeprecations: ['import'],
163          quietDeps: true,
164        },
165      },
166    },
167
168    define: {
169      // Vue 3 compile-time feature flags
170      __VUE_OPTIONS_API__: true,
171      __VUE_PROD_DEVTOOLS__: false,
172      __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
173      // Expose session storage toggle to client code
174      'import.meta.env.VITE_STORE_SESSION': JSON.stringify(
175        env.VITE_STORE_SESSION || env.STORE_SESSION || '',
176      ),
177    },
178
179    server: {
180      port: 8000,
181      // HTTPS is enabled via basicSsl plugin above
182      // Disable HMR WebSocket to avoid conflicts with app websockets
183      hmr: {
184        path: '/ws_hmr',
185      },
186      proxy: {
187        '/redfish': {
188          target: env.BASE_URL,
189          changeOrigin: true,
190          secure: false,
191          configure: (proxy) => {
192            proxy.on('proxyReq', (proxyReq, req) => {
193              injectAuthToken(proxyReq, req);
194
195              // Detect if this is a browser navigation vs an API call
196              const isApiCall =
197                req.headers['x-requested-with'] === 'XMLHttpRequest';
198
199              if (!isApiCall) {
200                proxyReq.setHeader(
201                  'Accept',
202                  'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
203                );
204                proxyReq.removeHeader('accept-encoding');
205              }
206
207              // Fix referer to match BMC host
208              if (req.headers.referer && env.BASE_URL) {
209                try {
210                  const refererUrl = new URL(req.headers.referer);
211                  const bmcUrl = new URL(env.BASE_URL);
212                  refererUrl.protocol = bmcUrl.protocol;
213                  refererUrl.hostname = bmcUrl.hostname;
214                  refererUrl.port = bmcUrl.port;
215                  proxyReq.setHeader('Referer', refererUrl.toString());
216                } catch (e) {
217                  // If URL parsing fails, leave referer unchanged
218                }
219              }
220
221              // Remove x-forwarded headers
222              proxyReq.removeHeader('x-forwarded-host');
223              proxyReq.removeHeader('x-forwarded-proto');
224              proxyReq.removeHeader('x-forwarded-port');
225              proxyReq.removeHeader('x-forwarded-for');
226            });
227            proxy.on('proxyRes', (proxyRes) => {
228              removeHsts(proxyRes);
229              delete proxyRes.headers['content-encoding'];
230            });
231          },
232        },
233        '/login': {
234          target: env.BASE_URL,
235          changeOrigin: true,
236          secure: false,
237          configure: (proxy) => {
238            proxy.on('proxyRes', removeHsts);
239          },
240        },
241        '/kvm': {
242          target: env.BASE_URL,
243          changeOrigin: true,
244          secure: false,
245          ws: true,
246          configure: (proxy) => {
247            proxy.on('proxyRes', removeHsts);
248            proxy.on('proxyReqWs', (proxyReq, req) => {
249              const cookies = req.headers.cookie;
250              if (cookies) {
251                const match = cookies.match(/X-Auth-Token=([^;]+)/);
252                if (match) {
253                  proxyReq.setHeader('X-Auth-Token', match[1]);
254                }
255              }
256            });
257            proxy.on('error', (err) => {
258              console.error('[vite] /kvm proxy error:', err.message);
259            });
260          },
261        },
262        '/console': {
263          target: env.BASE_URL,
264          changeOrigin: true,
265          secure: false,
266          ws: true,
267          configure: (proxy) => {
268            proxy.on('proxyRes', removeHsts);
269            proxy.on('proxyReqWs', (proxyReq, req) => {
270              // Forward the auth token from cookies for WebSocket connections
271              const cookies = req.headers.cookie;
272              if (cookies) {
273                const match = cookies.match(/X-Auth-Token=([^;]+)/);
274                if (match) {
275                  proxyReq.setHeader('X-Auth-Token', match[1]);
276                }
277              }
278            });
279            proxy.on('error', (err) => {
280              console.error('[vite] /console proxy error:', err.message);
281            });
282          },
283        },
284        '/vm': {
285          target: env.BASE_URL,
286          changeOrigin: true,
287          secure: false,
288          ws: true,
289          configure: (proxy) => {
290            proxy.on('proxyRes', removeHsts);
291            proxy.on('proxyReqWs', (proxyReq, req) => {
292              const cookies = req.headers.cookie;
293              if (cookies) {
294                const match = cookies.match(/X-Auth-Token=([^;]+)/);
295                if (match) {
296                  proxyReq.setHeader('X-Auth-Token', match[1]);
297                }
298              }
299            });
300            proxy.on('error', (err) => {
301              console.error('[vite] /vm proxy error:', err.message);
302            });
303          },
304        },
305        '/styles/redfish.css': {
306          target: env.BASE_URL,
307          changeOrigin: true,
308          secure: false,
309          configure: (proxy) => {
310            proxy.on('proxyReq', injectAuthToken);
311            proxy.on('proxyRes', removeHsts);
312          },
313        },
314        '/images/DMTF_Redfish_logo_2017.svg': {
315          target: env.BASE_URL,
316          changeOrigin: true,
317          secure: false,
318          configure: (proxy) => {
319            proxy.on('proxyReq', injectAuthToken);
320            proxy.on('proxyRes', removeHsts);
321          },
322        },
323      },
324    },
325
326    build: {
327      // Generate hashed filenames
328      rollupOptions: {
329        output: {
330          // Single chunk output (like LimitChunkCountPlugin with maxChunks: 1)
331          manualChunks: undefined,
332          entryFileNames: 'js/[name].[hash].js',
333          chunkFileNames: 'js/[name].[hash].js',
334          assetFileNames: (assetInfo) => {
335            if (assetInfo.name?.endsWith('.css')) {
336              return 'css/[name].[hash][extname]';
337            }
338            return 'assets/[name].[hash][extname]';
339          },
340        },
341      },
342      // Disable source maps in production
343      sourcemap: false,
344      // Performance hints
345      chunkSizeWarningLimit: 512,
346    },
347
348    // Handle .ico files
349    assetsInclude: ['**/*.ico'],
350
351    // Vitest configuration
352    test: {
353      globals: true,
354      environment: 'happy-dom',
355      setupFiles: ['./tests/vitest.setup.js'],
356      include: ['tests/unit/**/*.spec.js'],
357      css: false,
358      snapshotSerializers: ['vue3-snapshot-serializer'],
359      server: {
360        deps: {
361          inline: ['@carbon/icons-vue'],
362        },
363      },
364      coverage: {
365        provider: 'v8',
366        reporter: ['text', 'json', 'html'],
367      },
368    },
369  };
370});
371