import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; import { defineConfig, searchForWorkspaceRoot, createLogger } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; import pc from 'picocolors'; import qrcode from 'qrcode'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const useHttps = process.env.VITE_HTTPS === '1'; const certDir = path.resolve(__dirname, 'certs'); const keyPath = path.join(certDir, 'key.pem'); const certPath = path.join(certDir, 'cert.pem'); function httpsOptions() { if (!useHttps) return false; if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) { throw new Error( 'Missing TLS certs. Run: npm run cert (or ./scripts/generate-cert.sh)', ); } return { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath), }; } function truncate(text, max = 500) { if (text == null || text === '') return text; const s = typeof text === 'string' ? text : String(text); if (s.length <= max) return s; return `${s.slice(0, max)}… (${s.length} chars)`; } function isBinaryContentType(contentType = '') { const type = contentType.split(';')[0].trim().toLowerCase(); if (!type) return false; if (type.startsWith('image/')) return true; if (type === 'application/octet-stream') return true; return false; } function formatRequestBody(contentType, buffer) { if (!buffer || buffer.length === 0) return '(empty)'; if (isBinaryContentType(contentType)) { const type = contentType.split(';')[0].trim(); return `(binary ${type}, ${buffer.length} bytes)`; } return truncate(buffer.toString('utf8')); } function formatResponseBody(statusCode, contentType, buffer) { if (statusCode === 304) return '(304 no body)'; if (statusCode === 204 || !buffer || buffer.length === 0) return '(empty)'; if (isBinaryContentType(contentType)) { const type = contentType.split(';')[0].trim(); return `(binary ${type}, ${buffer.length} bytes)`; } return truncate(buffer.toString('utf8')); } function hasRequestBody(method) { return method === 'POST' || method === 'PUT' || method === 'PATCH'; } // dashgui is a local (symlinked) package with its own nested node_modules. // Its nested react/@mui/@emotion copies would otherwise get bundled in, // producing a duplicate React instance and "Cannot read properties of null // (reading 'useContext')" at runtime. Dedupe forces these to resolve from // dashfront's tree. (dashgui's own package.json "exports" map already points // the dashgui/* subpaths at its src/, so no alias is needed.) const sharedDeps = [ 'react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-router', 'react-router-dom', '@emotion/react', '@emotion/styled', '@mui/material', '@mui/icons-material', 'react-automat', ]; const sharedResolve = { dedupe: sharedDeps, alias: { 'node:module': path.resolve(__dirname, 'src/utils/emptyNodeModule.js'), 'react-automat': path.resolve(__dirname, '../automat'), }, }; const sharedServer = { // HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https) host: true, port: useHttps ? 4308 : 4209, https: httpsOptions(), allowedHosts: true, fs: { allow: [ searchForWorkspaceRoot(process.cwd()), path.resolve(__dirname, '..'), ], }, proxy: { '/api': { // Overridable for the visual testing rig (RIG_API_TARGET=http://localhost:4218). target: process.env.RIG_API_TARGET ?? 'http://localhost:4208', changeOrigin: true, cookieDomainRewrite: '', configure(proxy) { proxy.on('proxyReq', (_proxyReq, req) => { if (!hasRequestBody(req.method)) return; const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const buffer = chunks.length ? Buffer.concat(chunks) : null; req._proxyRequestBody = formatRequestBody( req.headers['content-type'] ?? '', buffer, ); }); }); proxy.on('proxyRes', (proxyRes, req) => { const raw = proxyRes.headers['set-cookie']; let cookies = raw ? (Array.isArray(raw) ? raw : [raw]) : null; // Backend sets Secure; browsers/WebViews on plain HTTP refuse it. if (cookies && !useHttps) { cookies = cookies.map((c) => c.replace(/;\s*Secure/gi, '')); proxyRes.headers['set-cookie'] = cookies; } const responseChunks = []; proxyRes.on('data', (chunk) => responseChunks.push(chunk)); proxyRes.on('end', () => { const responseBuffer = responseChunks.length ? Buffer.concat(responseChunks) : null; const parts = [ '[vite-proxy]', req.method, req.url, String(proxyRes.statusCode), ]; if (hasRequestBody(req.method)) { parts.push(`body=${req._proxyRequestBody ?? '(empty)'}`); } if (cookies?.length) { parts.push(`set-cookie=${cookies.map((c) => c.split(';')[0]).join(',')}`); } parts.push(formatResponseBody( proxyRes.statusCode, proxyRes.headers['content-type'] ?? '', responseBuffer, )); console.log(parts.join(' ')); }); }); }, }, }, }; function nginxHeadersPreviewPlugin() { return { name: 'nginx-headers-preview', configurePreviewServer(server) { server.middlewares.use((req, res, next) => { const url = (req.url || '').split('?')[0]; // Never touch API requests (proxied to backend) if (url.startsWith('/api')) { return next(); } // --- Security Headers (from deploy/nginx-dashfront.conf lines 72-76) --- res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Permissions-Policy', 'camera=(self), microphone=(), geolocation=()'); // --- Caching Rules (from deploy/nginx-dashfront.conf lines 129-166) --- if (/^\/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$/.test(url)) { // Zero caching for Service Worker & registration scripts res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } else if (/^\/(manifest\.webmanifest|favicon\.svg|icons\/.*|og-image\.png|quixpos-logo\.png)$/.test(url)) { // 1 hour for web manifest & icons / social share assets res.setHeader('Cache-Control', 'public, max-age=3600'); res.setHeader('Vary', 'Accept-Encoding'); } else if (url.startsWith('/assets/')) { // 1 year immutable for hashed assets res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.setHeader('Vary', 'Accept-Encoding'); } else if (url.endsWith('.wasm')) { // 1 year immutable for WASM binaries res.setHeader('Content-Type', 'application/wasm'); res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } else if (url === '/' || url === '/index.html' || !path.extname(url)) { // HTML5 SPA Entry / Fallback: Zero caching res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } next(); }); }, }; } function getListeningProcess(port) { try { const stdout = execSync(`lsof -nP -ti :${port} -sTCP:LISTEN`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], }).trim(); const pid = stdout.split('\n')[0]; if (!pid) return null; let cmd = ''; try { cmd = execSync(`ps -p ${pid} -o comm=`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], }).trim(); } catch { // ignore } return { pid, cmd }; } catch { return null; } } function createPidLogger() { const logger = createLogger(); const originalInfo = logger.info.bind(logger); logger.info = (msg, options) => { if (typeof msg === 'string') { const match = msg.match(/Port (\d+) is in use/); if (match) { const port = match[1]; const owner = getListeningProcess(port); if (owner) { const detail = owner.cmd ? ` (PID ${owner.pid}: ${owner.cmd})` : ` (PID ${owner.pid})`; msg = msg.replace(`Port ${port} is in use`, `Port ${port} is in use${pc.yellow(detail)}`); } } } originalInfo(msg, options); }; return logger; } function pidReporterPlugin() { const patchPrintUrls = (server) => { if (!server || server._pidReporterPatched) return; server._pidReporterPatched = true; const origPrintUrls = server.printUrls.bind(server); server.printUrls = () => { origPrintUrls(); const pmId = process.env.pm_id ? pc.dim(` (PM2 #${process.env.pm_id})`) : ''; server.config.logger.info( ` ${pc.green('➜')} ${pc.bold('PID')}: ${pc.cyan(process.pid)}${pmId}`, ); const networkUrls = server.resolvedUrls?.network ?? []; const primaryUrl = networkUrls.find((u) => !u.includes('172.17.') && !u.includes('169.254.')) || networkUrls[0] || server.resolvedUrls?.local?.[0]; if (primaryUrl) { qrcode.toString(primaryUrl, { type: 'terminal', small: true }, (err, qr) => { if (!err && qr) { server.config.logger.info( `\n ${pc.bold('Scan to open on mobile')} ${pc.dim(`(${primaryUrl})`)}:\n` + qr, ); } }); } }; }; return { name: 'pid-reporter', configureServer(server) { patchPrintUrls(server); }, configurePreviewServer(server) { patchPrintUrls(server); }, }; } export default defineConfig(({ command }) => ({ customLogger: createPidLogger(), resolve: sharedResolve, build: { emptyOutDir: false, rolldownOptions: { output: { strictExecutionOrder: true, codeSplitting: { groups: [ // Split the lazy routes into a handful of per-section chunks // (not one tiny chunk per route). Each section chunk groups the // section's pages together with the component trees used only by // that section, so first navigation to /artikel, /auftrag, … // loads just that section's code. Modules shared across sections // (api/, automats/, utils/, generic components/) fall out into a // common chunk by the bundler automatically. { priority: 20, name: 'appArticleComponents', test: /\/dashfront\/src\/components\/article\//, includeDependenciesRecursively: false, }, { priority: 10, name: 'appComponents', test: /\/dashfront\/src\/components\//, includeDependenciesRecursively: false, }, { priority: 5, name: 'app', test: /\/dashfront\/src\//, includeDependenciesRecursively: false, }, { priority: 150, name: 'react-vendor', test: /node_modules\/(react|react-dom|scheduler)\//, }, { priority: 140, name: 'router-vendor', test: /node_modules\/(react-router|react-router-dom)\//, }, { priority: 135, name: 'mui-icons', test: /node_modules\/@mui\/icons-material\//, }, { priority: 130, name: 'mui-vendor', test: /node_modules\/@mui\//, }, { priority: 125, name: 'emotion-vendor', test: /node_modules\/@emotion\//, }, { priority: 100, name: 'jsquash-image', test: /node_modules\/@jsquash\/(avif|resize)\//, }, { priority: 120, name: 'codemirror', test: /(?:node_modules\/(?:@codemirror|@uiw|@lezer|crelt|style-mod|w3c-keyname|@marijn\/find-cluster-break)|src\/components\/vfs\/CodeMirrorCore)/, }, { priority: 110, name: 'ag-grid-vendor', test: /node_modules\/(ag-grid-community|ag-grid-react|ag-stack)\//, }, { priority: 100, name: 'vendor', test: /node_modules\/(jsbarcode|sortablejs|notistack)\//, }, ], }, }, }, chunkSizeWarningLimit: 1000, modulePreload: { resolveDependencies(filename, deps, { hostType }) { if (hostType === 'html') { return deps.filter((dep) => !dep.includes('ag-grid') && !dep.includes('AutomatGrid') && !dep.includes('codemirror')); } return deps; }, }, }, plugins: [ pidReporterPlugin(), react(), nginxHeadersPreviewPlugin(), ...(command === 'build' ? [ VitePWA({ // prompt: new service worker waits in the background until the user // confirms update via the PWA update dialog. registerType: 'prompt', injectRegister: false, manifest: { id: '/', name: 'Dashfront', short_name: 'Dashfront', start_url: '/', scope: '/', display: 'standalone', background_color: '#85b7ad', theme_color: '#3b5bdb', icons: [ { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', }, { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', }, { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable', }, ], }, workbox: { clientsClaim: true, globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'], globIgnores: ['**/*.wasm'], navigateFallback: 'index.html', navigateFallbackDenylist: [/^\/api/], }, }), ] : [ { name: 'virtual-pwa-register-stub', resolveId(id) { if (id === 'virtual:pwa-register') return '\0virtual:pwa-register'; }, load(id) { if (id === '\0virtual:pwa-register') { return 'export function registerSW() { return () => {}; }'; } }, }, ]), ], server: sharedServer, preview: sharedServer, }));