import path from 'node:path' import fs from 'node:fs' import crypto from 'node:crypto' import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' import { viteSingleFile } from 'vite-plugin-singlefile' import { VitePWA } from 'vite-plugin-pwa' import VueJsx from '@vitejs/plugin-vue-jsx' import { execSync } from 'node:child_process' const BUEFY_CSS_REPLACEMENTS = new Map([ [ '@media screen and (max-width: calc(var(--bulma-sidebar-mobile-breakpoint) - 1px))', '@media screen and (max-width: 768px)' ], [ '@media screen and (max-width: var(--bulma-steps-mobile-max-width)-1px)', '@media screen and (max-width: 768px)' ], [ 'var(--bulma-$steps-divider-height)', 'var(--bulma-steps-divider-height)' ] ]) const projectRoot = fileURLToPath(new URL('.', import.meta.url)) const fromProjectRoot = (...segments) => path.join(projectRoot, ...segments) const PUBLIC_ASSET_ALIASES = [ ['favicons/favicon-96x96.png', 'assets/favicons/favicon-96x96.png'], ['favicons/favicon.svg', 'assets/favicons/favicon.svg'], ['favicons/favicon.ico', 'assets/favicons/favicon.ico'], ['favicons/apple-touch-icon.png', 'assets/favicons/apple-touch-icon.png'], ['favicons/web-app-manifest-192x192.png', 'assets/favicons/web-app-manifest-192x192.png'], ['favicons/web-app-manifest-512x512.png', 'assets/favicons/web-app-manifest-512x512.png'], ] const DEFAULT_RELEASE_FRONTEND_BASE_PATH = './' function normalizeReleaseFrontendBasePath(value) { const rawValue = String(value || '').trim() let basePath = rawValue if (/^https?:\/\//i.test(rawValue)) { try { basePath = new URL(rawValue).pathname } catch { basePath = '' } } if (!basePath || basePath === '/') { return '/' } if (basePath === './') { return './' } return `/${basePath.replace(/^\/+|\/+$/g, '')}/` } function releaseFrontendBasePath() { return normalizeReleaseFrontendBasePath( process.env.VITE_FRONTEND_BASE_PATH || process.env.VITE_BASE_PATH || process.env.RELEASE_FRONTEND_BASE_PATH || DEFAULT_RELEASE_FRONTEND_BASE_PATH ) } function releaseManifestPath(pathname) { const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}` const basePath = releaseFrontendBasePath() if (basePath === '/' || basePath === './') { return normalizedPathname } const basePrefix = basePath.replace(/\/+$/g, '') if (normalizedPathname === basePrefix) { return '/' } if (normalizedPathname.startsWith(`${basePrefix}/`)) { return normalizedPathname.slice(basePrefix.length) || '/' } return normalizedPathname } function releaseManifestAssetPath(value) { return String(value || '').replace(/^\/+/, '') } function getGitCommit() { const envCommit = firstReleaseCommitEnv() if (envCommit) { return envCommit.slice(0, 12) } try { return execSync('git rev-parse --short HEAD').toString().trim() } catch { return 'unknown' } } function getGitCommitSha() { const envCommit = firstReleaseCommitEnv() if (envCommit) { return envCommit } try { return execSync('git rev-parse HEAD').toString().trim() } catch { return 'unknown' } } function firstReleaseCommitEnv() { for (const key of ['RELEASE_COMMIT_SHA', 'SOURCE_COMMIT', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH']) { const value = String(process.env[key] || '').trim() if (/^[0-9a-f]{7,40}$/i.test(value)) { return value } } return '' } function releaseBuildId(commitSha) { const explicitBuildId = process.env.RELEASE_BUILD_ID || process.env.BUILD_ID if (explicitBuildId) { return explicitBuildId } if (process.env.GITHUB_RUN_ID) { return [process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT].filter(Boolean).join('-') } const prefix = commitSha !== 'unknown' ? commitSha.slice(0, 12) : 'local' return `${prefix}-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}` } function releaseUrlPath(value, basePath = '/') { if (!value || value.startsWith('#') || /^(?:data|mailto|tel|javascript):/i.test(value)) { return '' } try { const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}` const url = new URL(value, `https://release.local${normalizedBasePath}`) if (url.origin !== 'https://release.local') { return '' } return releaseManifestPath(url.pathname) } catch { return '' } } function releaseManifestDistPath(outputDirectory, urlPath) { try { const normalizedPath = decodeURIComponent( releaseManifestPath(new URL(urlPath, 'https://release.local').pathname) ).replace(/^\/+/, '') const resolvedPath = path.resolve(outputDirectory, normalizedPath) const resolvedOutputDirectory = path.resolve(outputDirectory) if ( resolvedPath !== resolvedOutputDirectory && !resolvedPath.startsWith(`${resolvedOutputDirectory}${path.sep}`) ) { return '' } return resolvedPath } catch { return '' } } function releaseManifestHash(filePath) { const contents = fs.readFileSync(filePath) return { sha256: crypto.createHash('sha256').update(contents).digest('hex'), bytes: contents.length } } function releaseDistAssetUrls(outputDirectory) { const assetUrls = [] const walk = (directory) => { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const absolutePath = path.join(directory, entry.name) const relativePath = path.relative(outputDirectory, absolutePath).replace(/\\/g, '/') if (entry.isDirectory()) { walk(absolutePath) continue } if (!entry.isFile()) { continue } if (relativePath === 'release-manifest.json' || relativePath === '.htaccess') { continue } assetUrls.push(relativePath) } } walk(outputDirectory) return assetUrls } function patchBuefyCssMediaQuery() { return { name: 'patch-buefy-css-media-query', enforce: 'pre', transform(code, id) { if (!id.includes('buefy/dist/css/buefy.css')) { return null } let nextCode = code let didPatch = false for (const [invalidSnippet, fixedSnippet] of BUEFY_CSS_REPLACEMENTS.entries()) { if (!nextCode.includes(invalidSnippet)) { continue } nextCode = nextCode.replaceAll(invalidSnippet, fixedSnippet) didPatch = true } if (!didPatch) { return null } return nextCode } } } function releaseEntryManifest() { return { name: 'release-entry-manifest', generateBundle(_, bundle) { const chunks = Object.values(bundle).filter((item) => item.type === 'chunk') const mainChunk = chunks.find((chunk) => String(chunk.facadeModuleId || '').replace(/\\/g, '/').endsWith('/src/main.js') ) || chunks.find((chunk) => chunk.name === 'main' && /^assets\/main-[\w-]+\.js$/.test(chunk.fileName)) if (!mainChunk) { this.warn('Could not find src/main.js chunk for release-entry.json') return } const css = Array.from(mainChunk.viteMetadata?.importedCss || []) this.emitFile({ type: 'asset', fileName: 'release-entry.json', source: JSON.stringify( { entry: mainChunk.fileName, css }, null, 2 ) }) } } } function publicAssetAliases() { return { name: 'public-asset-aliases', generateBundle() { for (const [source, fileName] of PUBLIC_ASSET_ALIASES) { const sourcePath = fromProjectRoot('public', source) if (!fs.existsSync(sourcePath)) { this.warn(`Could not find public asset alias source: ${source}`) continue } this.emitFile({ type: 'asset', fileName, source: fs.readFileSync(sourcePath) }) } } } } function rootPwaManifestAlias() { return { name: 'root-pwa-manifest-alias', writeBundle(options) { const configuredOutputDirectory = options.dir || 'dist' const outputDirectory = path.isAbsolute(configuredOutputDirectory) ? configuredOutputDirectory : fromProjectRoot(configuredOutputDirectory) const sourcePath = path.join(outputDirectory, 'assets', 'manifest.webmanifest') const targetPath = path.join(outputDirectory, 'manifest.webmanifest') if (!fs.existsSync(sourcePath)) { this.warn('Could not find assets/manifest.webmanifest for root manifest alias') return } const manifest = JSON.parse(fs.readFileSync(sourcePath, 'utf8')) manifest.icons = (manifest.icons || []).map((icon) => { if ( !icon.src || icon.src.startsWith('/') || icon.src.startsWith('assets/') || /^[a-z][a-z0-9+.-]*:/i.test(icon.src) ) { return icon } return { ...icon, src: `assets/${icon.src}` } }) fs.writeFileSync(targetPath, JSON.stringify(manifest)) } } } function releaseMetadataManifest() { let outputDirectory = fromProjectRoot('dist') return { name: 'release-metadata-manifest', enforce: 'post', configResolved(config) { outputDirectory = path.isAbsolute(config.build.outDir) ? config.build.outDir : path.resolve(config.root, config.build.outDir) }, closeBundle() { const releaseEntryPath = path.join(outputDirectory, 'release-entry.json') const indexPath = path.join(outputDirectory, 'index.html') if (!fs.existsSync(releaseEntryPath)) { this.error('Could not find release-entry.json for release-manifest.json') return } if (!fs.existsSync(indexPath)) { this.error('Could not find index.html for release-manifest.json') return } const releaseEntry = JSON.parse(fs.readFileSync(releaseEntryPath, 'utf8')) const indexHtml = fs.readFileSync(indexPath, 'utf8') const rootFiles = fs.readdirSync(outputDirectory) const emittedAssetUrls = releaseDistAssetUrls(outputDirectory) const indexAssetUrls = Array.from(indexHtml.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) .map((match) => releaseUrlPath(match[1], '/index.html')) .map(releaseManifestAssetPath) .filter(Boolean) const releaseEntryAssetUrls = [ releaseEntry.entry ? releaseManifestAssetPath(releaseEntry.entry) : '', ...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map(releaseManifestAssetPath) : []) ].filter(Boolean) const pwaAssetUrls = [ 'manifest.json', 'manifest.webmanifest', 'assets/manifest.webmanifest', 'favicon.ico', 'favicon_default.ico', 'pleno-favicon.ico', 'registerSW.js', 'sw.js', ...rootFiles.filter((fileName) => /^workbox-[^/]+\.js$/.test(fileName)) ] .filter((fileName) => fs.existsSync(path.join(outputDirectory, fileName))) .map((fileName) => releaseManifestAssetPath(fileName.replace(/\\/g, '/'))) for (const manifestPath of ['manifest.webmanifest', 'assets/manifest.webmanifest']) { const absoluteManifestPath = path.join(outputDirectory, manifestPath) if (!fs.existsSync(absoluteManifestPath)) { continue } try { const pwaManifest = JSON.parse(fs.readFileSync(absoluteManifestPath, 'utf8')) for (const icon of pwaManifest.icons || []) { const iconPath = releaseUrlPath(icon.src || '', `/${manifestPath}`) if (iconPath) { pwaAssetUrls.push(releaseManifestAssetPath(iconPath)) } } } catch { this.warn(`Could not parse ${manifestPath} while generating release-manifest.json`) } } const criticalAssetUrls = Array.from(new Set([ 'index.html', 'release-entry.json', ...emittedAssetUrls, ...indexAssetUrls, ...releaseEntryAssetUrls, ...pwaAssetUrls ])) const missingAssets = [] const assetHashes = {} for (const assetUrl of criticalAssetUrls) { const assetPath = releaseManifestDistPath(outputDirectory, assetUrl) if (!assetPath || !fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) { missingAssets.push(assetUrl) continue } assetHashes[assetUrl] = releaseManifestHash(assetPath) } if (missingAssets.length > 0) { this.error(`Critical release assets are missing from dist: ${missingAssets.join(', ')}`) return } const commitSha = getGitCommitSha() fs.writeFileSync( path.join(outputDirectory, 'release-manifest.json'), JSON.stringify( { schema_version: 1, build_id: releaseBuildId(commitSha), commit_sha: commitSha, created_at: new Date().toISOString(), entry: releaseEntry.entry, css: Array.isArray(releaseEntry.css) ? releaseEntry.css : [], index_asset_urls: Array.from(new Set(indexAssetUrls)), pwa_asset_urls: Array.from(new Set(pwaAssetUrls)), asset_urls: criticalAssetUrls, asset_hashes: assetHashes }, null, 2 ) ) } } } const DEFAULT_API_PROXY_TARGET = 'https://api-v2.truckwash.io' const DEFAULT_API_PROXY_BASE_PATH = '/master/api' const PLAYWRIGHT_OPTIMIZED_DEPS = [ '@azure/msal-browser', '@popperjs/core', '@vueuse/core', '@vueuse/head', 'apexcharts', 'axios', 'buefy', 'chart.js', 'sweetalert2', 'vue', 'vue-i18n', 'vue-router', 'vue-toast-notification/src/index.js', 'vue3-apexcharts', 'vuex' ] function normalizeProxyBasePath(value) { const normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '') return normalized ? `/${normalized}` : '' } function parseProxySecure(value) { return String(value || '').trim().toLowerCase() !== 'false' } function rewriteApiProxyPath(requestPath, basePath = '') { const pathWithoutApiPrefix = String(requestPath || '/').replace(/^\/api(?=\/|\?|$)/, '') || '/' if (!basePath) { return pathWithoutApiPrefix.startsWith('?') ? `/${pathWithoutApiPrefix}` : pathWithoutApiPrefix } if (pathWithoutApiPrefix === '/') { return basePath } return `${basePath}${pathWithoutApiPrefix}` } export function createApiProxyOptions(env = process.env) { const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false' const configuredTarget = String(env.VITE_API_PROXY_TARGET || '').trim() const target = configuredTarget || DEFAULT_API_PROXY_TARGET const secure = parseProxySecure(env.VITE_API_PROXY_SECURE) const basePath = env.VITE_API_PROXY_BASE_PATH !== undefined ? normalizeProxyBasePath(env.VITE_API_PROXY_BASE_PATH) : configuredTarget ? '' : DEFAULT_API_PROXY_BASE_PATH return { target, changeOrigin: true, secure, ...(stripPrefix ? { rewrite: (requestPath) => rewriteApiProxyPath(requestPath, basePath) } : {}) } } export default defineConfig(({ mode }) => { const isProd = mode === 'production' const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1' // Set COMMIT_HASH env var for use in the app const version = process.env.npm_package_version || '0.0.0' const commit = getGitCommit() console.log(`Building version ${version} (commit: ${commit})`) process.env.COMMIT_HASH = commit process.env.APP_VERSION = version process.env.IS_DEV = !isProd ? 'true' : 'false' // Server version updates happen after deploy verification, not during asset builds. // Keep production assets host-agnostic. Release channel prefixes are resolved at runtime. const base = isProd ? releaseFrontendBasePath() : '/' const pwaScope = base === './' ? '.' : base // Enable single-file build only when explicitly requested (disabled by default for PWA) const enableSingleFile = process.env.VITE_SINGLE_FILE === 'true' && !isProd return { base, plugins: [ patchBuefyCssMediaQuery(), vue(), VueJsx(), releaseEntryManifest(), publicAssetAliases(), !isProd && !isPlaywrightRuntime && vueDevTools(), enableSingleFile && viteSingleFile(), VitePWA({ registerType: 'autoUpdate', injectRegister: isProd ? 'auto' : false, manifestFilename: 'assets/manifest.webmanifest', strategies: 'generateSW', devOptions: { enabled: false // keep for local dev }, workbox: { maximumFileSizeToCacheInBytes: 12 * 1024 * 1024, cleanupOutdatedCaches: true, skipWaiting: true, clientsClaim: true, globPatterns: ['**/*.{js,css,html,ico,svg,png,woff2}'], globIgnores: ['**/registerSW.js', '**/sw.js', '**/*.webmanifest'], runtimeCaching: [ { // cache API responses urlPattern: ({ url }) => url.origin === 'https://api-v2.truckwash.io', handler: 'NetworkFirst', options: { cacheName: 'pleno-api-cache', expiration: { maxEntries: 100, maxAgeSeconds: 5 * 60 // 5 minutes }, networkTimeoutSeconds: 10, cacheableResponse: { statuses: [200] // avoid opaque caching for API } } }, { // cache same-origin static assets urlPattern: ({ sameOrigin, request }) => sameOrigin && ['style', 'script', 'font'].includes(request.destination), handler: 'StaleWhileRevalidate', options: { cacheName: 'pleno-website-cache', expiration: { maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 // 24 hours }, cacheableResponse: { statuses: [0, 200] } } }, { // cache images (restrict to same-origin; loosen if you use a trusted CDN) urlPattern: ({ sameOrigin, request }) => sameOrigin && request.destination === 'image', handler: 'CacheFirst', options: { cacheName: 'pleno-image-cache', expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 // 7 days }, cacheableResponse: { statuses: [0, 200] } } } ] }, manifest: { name: 'Truck Wash Kundeportal', short_name: 'Truck Wash', description: 'Access your Truck Wash accounts and transactions from anywhere.', theme_color: '#063651', background_color: '#0787bb', // Use relative scope when base is relative start_url: pwaScope, scope: pwaScope, display: 'standalone', orientation: 'portrait', launch_handler: { client_mode: 'navigate-existing' }, icons: [ { src: 'favicons/web-app-manifest-192x192.png', sizes: '192x192', type: 'image/png' }, { src: 'favicons/web-app-manifest-512x512.png', sizes: '512x512', type: 'image/png' }, // Optional: add a maskable icon // { src: 'icons/icon-512x512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' } ], id: '/' }, useCredentials: true }), rootPwaManifestAlias(), releaseMetadataManifest() ].filter(Boolean), resolve: { alias: { '@': fromProjectRoot('src'), 'vue-router': fromProjectRoot('node_modules', 'vue-router', 'dist', 'vue-router.mjs'), 'vue-i18n': fromProjectRoot('node_modules', 'vue-i18n', 'dist', 'vue-i18n.mjs') }, preserveSymlinks: true, dedupe: ['vue', 'vue-router', 'vue-i18n', '@vueuse/core', '@vueuse/head', '@unhead/vue'] }, define: { 'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString()), 'import.meta.env.VITE_APP_VERSION': JSON.stringify(process.env.APP_VERSION || '0.0.0'), 'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(commit), 'import.meta.env.VITE_IS_PLAYWRIGHT': JSON.stringify(isPlaywrightRuntime), // Tip: import.meta.env.DEV/PROD are available at runtime 'import.meta.env.VITE_IS_DEV': JSON.stringify(!isProd), }, css: { preprocessorOptions: { scss: { api: 'modern-compiler', quietDeps: true, silenceDeprecations: ['import', 'global-builtin', 'legacy-js-api', 'if-function'] } } }, test: { setupFiles: [fromProjectRoot('tests', 'unit', 'setup.js')], isolate: true }, server: { proxy: { '/api': createApiProxyOptions() }, watch: { ignored: ['**/output/playwright/**', '**/node_modules.codex-backup/**'] } }, optimizeDeps: isPlaywrightRuntime ? { entries: ['index.html', 'src/**/*.{vue,js,ts,jsx,tsx}'], include: PLAYWRIGHT_OPTIMIZED_DEPS } : { entries: ['index.html', 'src/**/*.{vue,js,ts,jsx,tsx}'] } } })