import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; import { readdirSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); // ──────────────────────────────────────────────────────────────────────── // Sticker manifest plugin // ──────────────────────────────────────────────────────────────────────── // // Exposes a virtual module `virtual:sticker-manifest` that emits a list of // sticker filenames present in `public/stickers/` at build time. The client // imports this list to render the Stickers tab without paying for a runtime // directory listing or shipping a separately-maintained manifest file. // // Why a virtual module rather than `import.meta.glob`: // Vite's docs explicitly state that files in `public/` should not be // imported — they're served as-is, untouched by the build. So glob globs // against `public/` don't work. A virtual module is the documented escape // hatch when we want build-time access to public assets without giving up // their stable, hash-free URLs. // // Stable URLs matter here because the SERVER ALSO needs to read these files // during export (see server.js → resolveImageSource). If we used /src/assets/ // the URLs would be hashed in prod and the server couldn't recover the // original filename. Keeping the stickers in public/ means the URL is // `/stickers/` everywhere, and the server reads from // `public/stickers/` (dev) or `dist/stickers/` (prod). // // On change: in dev, the plugin watches the directory and triggers an HMR // reload when files are added or removed. Adding a sticker means: drop the // file in, the next page render picks it up. No restart. function stickerManifestPlugin() { const VIRTUAL_ID = 'virtual:sticker-manifest'; const RESOLVED_ID = '\0' + VIRTUAL_ID; const STICKERS_DIR = join(__dirname, 'public', 'stickers'); const VALID_EXT = /\.(png|webp|jpe?g|svg)$/i; function readStickerFilenames() { if (!existsSync(STICKERS_DIR)) return []; try { return readdirSync(STICKERS_DIR).filter((f) => VALID_EXT.test(f)).sort(); } catch { return []; } } return { name: 'sticker-manifest', resolveId(id) { if (id === VIRTUAL_ID) return RESOLVED_ID; return null; }, load(id) { if (id !== RESOLVED_ID) return null; const filenames = readStickerFilenames(); // The manifest is just the filename list. URL construction is the // client's job (always `/stickers/`); doing it here would // bake in assumptions about the public path prefix. return `export const STICKER_FILES = ${JSON.stringify(filenames)};\n`; }, configureServer(server) { // HMR: when files in public/stickers/ change, invalidate the virtual // module so the next import re-runs and the client sees the new list. // Vite's default file watcher already watches public/ for plain file // serving; we just need to react to the events. server.watcher.add(STICKERS_DIR); const onChange = (path) => { if (!path.startsWith(STICKERS_DIR)) return; const mod = server.moduleGraph.getModuleById(RESOLVED_ID); if (mod) { server.moduleGraph.invalidateModule(mod); server.ws.send({ type: 'full-reload' }); } }; server.watcher.on('add', onChange); server.watcher.on('unlink', onChange); // We deliberately don't react to 'change' — a sticker file being // overwritten with new bytes doesn't change the manifest (still the // same filename); the browser will re-fetch when the user reloads. }, }; } export default defineConfig({ plugins: [ react(), stickerManifestPlugin(), VitePWA({ registerType: 'prompt', // We register the SW manually via `useRegisterSW` in PWAInstall, so the plugin // shouldn't inject a registration script. injectRegister: false, includeAssets: ['favicon.svg', 'pwa-192x192.svg', 'pwa-512x512.svg'], manifest: { name: 'Apparel Designer', short_name: 'ApparelDesigner', description: 'T-shirt customization editor', theme_color: '#38bdf8', background_color: '#ffffff', display: 'standalone', orientation: 'any', scope: '/', start_url: '/', icons: [ { src: 'pwa-192x192.svg', sizes: '192x192', type: 'image/svg+xml' }, { src: 'pwa-512x512.svg', sizes: '512x512', type: 'image/svg+xml' }, { src: 'pwa-512x512.svg', sizes: '512x512', type: 'image/svg+xml', purpose: 'any maskable' }, ], }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], // Stickers live in /stickers/ and may be many in number. Don't // precache them — the whole point of `loading="lazy"` on the // tags is to skip fetching stickers the user never sees, // which precaching would undo. They're served via the // `sticker-library` runtimeCaching rule below instead. globIgnores: ['**/stickers/**'], runtimeCaching: [ { urlPattern: /^https:\/\/cdn\.huggingface\.co\/.*/i, handler: 'CacheFirst', options: { cacheName: 'transformers-models', expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 30 }, cacheableResponse: { statuses: [0, 200] }, }, }, { urlPattern: /^https:\/\/cdn-lfs\.huggingface\.co\/.*/i, handler: 'CacheFirst', options: { cacheName: 'transformers-lfs', expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 30 }, cacheableResponse: { statuses: [0, 200] }, }, }, { urlPattern: /^\/uploads\/.*/i, handler: 'CacheFirst', options: { cacheName: 'uploaded-images', expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 7 }, }, }, { urlPattern: /^\/api\/.*/i, handler: 'NetworkFirst', options: { cacheName: 'api-responses', expiration: { maxEntries: 50, maxAgeSeconds: 300 }, cacheableResponse: { statuses: [0, 200] }, networkTimeoutSeconds: 3, }, }, { urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, handler: 'StaleWhileRevalidate', options: { cacheName: 'google-fonts', expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 }, }, }, { urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, handler: 'CacheFirst', options: { cacheName: 'gstatic-fonts', expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 }, }, }, { // Stickers are static brand assets — once fetched, they're // safe to cache aggressively. CacheFirst minimizes network // traffic when the user reopens the editor. Capacity (200) // is comfortably more than a typical library; new stickers // pushed via a deploy will displace old ones on access. urlPattern: /^\/stickers\/.*/i, handler: 'CacheFirst', options: { cacheName: 'sticker-library', expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }, cacheableResponse: { statuses: [0, 200] }, }, }, ], }, }), ], server: { port: 3000, proxy: { '/api': { target: 'http://localhost:3001', changeOrigin: true }, '/uploads': { target: 'http://localhost:3001', changeOrigin: true }, '/exports': { target: 'http://localhost:3001', changeOrigin: true }, }, }, build: { outDir: 'dist' }, });