import { createReadStream, existsSync, statSync } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; import path from 'node:path'; import react from '@vitejs/plugin-react'; import { defineConfig, loadEnv, type Plugin, type ProxyOptions } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; import { buildVersionFromDate } from './src/buildVersion.js'; // Web build: public at https://dev.aggtaa.com/m/, the reverse proxy strips "/m" (X-Forwarded-Prefix). // Capacitor build (--mode capacitor): served from the app bundle root. // // Env (mobile-client/.env or process env): // PORT dev/preview server port (default 10003) // ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com) // BACKEND_URL local dev only: server-be address (default http://127.0.0.1:10002) // CARDY_UPDATE_URL where the Android app looks for live updates (default https://dev.aggtaa.com/m/android) // CARDY_BUILD_VERSION override the build version (default: from the build time) const WEB_BASE = '/m/'; /** Published Android updates (scripts/publish-android-update.mjs, publish-android-apk.mjs), served at /m/android/. */ const UPDATES_DIR = path.resolve('updates/android'); const UPDATES_PATH = `${WEB_BASE}android/`; type Next = () => void; /** The proxy strips /m; put it back so Vite's base-aware dev and preview servers find the files. */ function restoreStrippedBase(req: IncomingMessage, _res: ServerResponse, next: Next) { const url = req.url ?? '/'; if (!url.startsWith(WEB_BASE) && !url.startsWith('/api/') && url !== '/api') req.url = WEB_BASE.slice(0, -1) + url; next(); } /** version.json and bundle zips for the Android app; CORS is open because the app runs on https://localhost. */ function serveAndroidUpdates(req: IncomingMessage, res: ServerResponse, next: Next) { const url = (req.url ?? '').split('?')[0] ?? ''; if (!url.startsWith(UPDATES_PATH) || (req.method !== 'GET' && req.method !== 'HEAD')) return next(); const name = decodeURIComponent(url.slice(UPDATES_PATH.length)); const file = path.join(UPDATES_DIR, name); res.setHeader('Access-Control-Allow-Origin', '*'); if (!/^[A-Za-z0-9._-]+$/.test(name) || !existsSync(file)) { res.statusCode = 404; res.end(); return; } const isJson = name.endsWith('.json'); res.setHeader( 'Content-Type', isJson ? 'application/json' : name.endsWith('.apk') ? 'application/vnd.android.package-archive' : 'application/zip', ); if (!isJson) res.setHeader('Content-Length', String(statSync(file).size)); // Bundle files are named by version and never change. res.setHeader('Cache-Control', isJson ? 'no-cache' : 'public, max-age=31536000, immutable'); if (req.method === 'HEAD') { res.end(); return; } createReadStream(file).pipe(res); } function cardyServer(): Plugin { const install = (use: (handler: (req: IncomingMessage, res: ServerResponse, next: Next) => void) => void) => { use(restoreStrippedBase); use(serveAndroidUpdates); }; return { name: 'cardy-server', configureServer: (server) => install((h) => server.middlewares.use(h)), configurePreviewServer: (server) => install((h) => server.middlewares.use(h)), }; } /** Writes cardy-version.json next to the build: read by the update publishing script. */ function emitVersionFile(version: string): Plugin { return { name: 'cardy-version-file', apply: 'build', generateBundle() { this.emitFile({ type: 'asset', fileName: 'cardy-version.json', source: JSON.stringify({ version }) }); }, }; } export default defineConfig(({ mode, command }) => { const env = loadEnv(mode, process.cwd(), ''); const capacitor = mode === 'capacitor'; const buildVersion = process.env.CARDY_BUILD_VERSION || (command === 'build' ? buildVersionFromDate(new Date()) : 'dev'); // Local development without the reverse proxy: /api/* -> server-be with /api stripped. const proxy: Record = { '/api': { target: env.BACKEND_URL || 'http://127.0.0.1:10002', rewrite: (p) => p.replace(/^\/api/, '') || '/', headers: { 'X-Forwarded-Prefix': '/api' }, }, }; const server = { host: true, port: Number(env.PORT) || 10003, strictPort: true, allowedHosts: (env.ALLOWED_HOSTS || 'dev.aggtaa.com').split(',').map((h) => h.trim()), proxy, }; return { base: capacitor ? './' : WEB_BASE, define: { __BUILD_VERSION__: JSON.stringify(buildVersion), __UPDATE_URL__: JSON.stringify((env.CARDY_UPDATE_URL || 'https://dev.aggtaa.com/m/android').replace(/\/+$/, '')), }, plugins: [ react(), cardyServer(), ...(capacitor ? [emitVersionFile(buildVersion)] : []), VitePWA({ disable: capacitor, registerType: 'autoUpdate', injectRegister: 'auto', includeAssets: ['icon.svg'], manifest: { name: 'Cardy', short_name: 'Cardy', description: 'Discount cards', theme_color: '#0f1115', background_color: '#0f1115', display: 'standalone', orientation: 'portrait', start_url: '.', scope: '.', icons: [ { src: 'icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }, { src: 'icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' }, ], }, workbox: { globPatterns: ['**/*.{js,css,html,svg,wasm,webmanifest}'], // bwip-js and the zxing wasm module are large but needed offline. maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, navigateFallback: 'index.html', navigateFallbackDenylist: [/^\/api(\/|$)/, /\/android\//], }, }), ], build: { outDir: capacitor ? 'dist-capacitor' : 'dist', emptyOutDir: true, chunkSizeWarningLimit: 1100, }, server, preview: server, }; });