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/**'], // Note: we deliberately do NOT raise `maximumFileSizeToCacheInBytes` // here. The default 2 MiB cap is a useful regression alarm — if // a future change pushes any chunk past it, the build fails loudly // and we know to either split further or investigate why a chunk // grew. Earlier this file overrode the cap to 5 MiB because the // pre-split bundle was 2.32 MB; that override was removed once // `build.rollupOptions.output.manualChunks` (below in this config) // brought every chunk under the default cap. 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', // ──────────────────────────────────────────────────────────────── // Manual chunk splitting // ──────────────────────────────────────────────────────────────── // // Why we split rather than ship one bundle // ───────────────────────────────────────── // 1. Workbox precache cap. The PWA plugin's default // `maximumFileSizeToCacheInBytes` is 2 MiB. A single bundle // containing Konva + react-konva + @huggingface/transformers + // react + filerobot + everything else easily clears 2.3 MB, // which fails the precache step. Splitting keeps every chunk // comfortably under the cap without needing to override it. // // 2. Browser cache reuse across deploys. When app code changes // (the common case during active development), only the // `index` chunk's hash flips; vendor chunks (Konva, React, // transformers) keep their old hash and stay cached in users' // browsers. That means returning users only re-download the // relatively-small app chunk, not the 1-MB-each library // chunks that haven't changed. // // 3. Parallel download. Browsers can fetch multiple chunks // concurrently on HTTP/2, so loading 4 × 600 KB chunks is // typically faster than 1 × 2.3 MB chunk on broadband, even // before considering caching. // // What goes where // ─────────────── // transformers/ @huggingface/transformers \u2014 the in-browser // ML runtime used for background removal. // Biggest single dependency. Only the // inference engine is bundled here; the // actual ML model weights stream from the // huggingface CDN at runtime (see // `transformers-models` / `transformers-lfs` // runtimeCaching rules above for offline // re-use). // konva/ konva + react-konva + use-image \u2014 the canvas // stack. All three are needed together // because react-konva wraps konva and // use-image wraps it for React consumption. // Grouping prevents one from being inlined // into a chunk that doesn't otherwise need // the others. // react-vendor/ react + react-dom \u2014 stable, every visitor // needs them, perfect for long-term caching. // filerobot/ react-filerobot-image-editor \u2014 mid-size, // used only when the user opens the advanced // image editor. Worth its own chunk so the // rest of the app doesn't pay for it. // index/ Everything else: app code, react-select, // styled-components, zod, uuid, plus all // the small utilities. This stays the // "main" chunk that changes with each deploy. // // What we deliberately did NOT split // ────────────────────────────────── // The smaller deps (react-select, styled-components, zod, uuid, // @emotion/is-prop-valid) stay in the index chunk on purpose. // Pulling them out would create chunks under 50 KB each, which // adds HTTP overhead (per-request connection cost, separate // hash entries in the precache manifest) without meaningfully // helping cache reuse. The "many small chunks" antipattern is // worse than a moderately-sized index chunk that includes the // long tail. // // If a future change makes one of these grow significantly (say, // a styled-components major upgrade that doubles its size), it // becomes worth pulling into its own chunk \u2014 but only then. // // Maintenance: when adding a new heavy dependency // ─────────────────────────────────────────────── // 1. Build and check `dist/assets/` for which chunk it landed in. // 2. If it bloated the `index` chunk past comfort (rule of thumb: // if index alone is > 1 MiB), add a new entry to manualChunks // targeting that package name. // 3. The 2 MiB workbox cap acts as the loud alarm \u2014 if you forget, // the build fails and tells you exactly which chunk overflowed. rollupOptions: { output: { manualChunks: { 'transformers': ['@huggingface/transformers'], 'konva': ['konva', 'react-konva', 'use-image'], 'react-vendor': ['react', 'react-dom'], 'filerobot': ['react-filerobot-image-editor'], }, }, }, }, });