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/**'], // 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. // // History: this file briefly overrode the cap to 3 MiB when the // goods-editor module shipped its dist with konva, react-konva, // use-image, react-filerobot-image-editor, and // @huggingface/transformers inlined (~2.14 MiB chunk). That // override was removed when the module externalized those deps // (goods-editor v0.2.0-alpha.0+) and they became host-resolved // peer deps, letting the host's manualChunks rules split them // into separate sub-2-MiB chunks. 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. // // maxEntries sized to cover the full library (~1,600 files // at this writing) with headroom for growth. The module's // useStickerPrefetch hook fills this cache proactively on // tab-open; without enough capacity, prefetched stickers // would evict each other and the prefetch would be wasted. // Browser storage quotas (typically tens to hundreds of MB) // remain the ultimate ceiling — maxEntries just stops // Workbox from evicting earlier than the browser would. urlPattern: /^\/stickers\/.*/i, handler: 'CacheFirst', options: { cacheName: 'sticker-library', expiration: { maxEntries: 2000, maxAgeSeconds: 60 * 60 * 24 * 30 }, cacheableResponse: { statuses: [0, 200] }, }, }, ], }, }), ], // Resolution config for the goods-editor module. // ─────────────────────────────────────────────────────────────── // The module is installed from Gitea via a git+ssh dependency // (see host package.json). npm clones the release branch into // node_modules/goods-editor/, which contains pre-built dist/. // The host consumes the module exactly as any other consumer // would: through package.json resolution to dist/goods-editor.js // and dist/goods-editor.css. // // No aliases here. Earlier versions of this config aliased to // source for HMR, but that bypassed the dist entirely and hid // bugs that would have surfaced in deployment (dual-React from // inline-bundled React, stale dist CSS, CJS require errors). // // The module's build (rollup.config.js inside goods-editor-module) // externalizes React properly using @rollup/plugin-commonjs to // handle the CJS interop that Vite 8's Rolldown couldn't. With // React external, there's only one React instance in the final // bundle by construction — no dedupe trickery needed at runtime. // // What stays // ────────── // `dedupe: ['react', ...]` remains as cheap insurance. If any // transitive ever bundles React inline, dedupe collapses it to // the host's copy. With proper externalization this should be a // no-op, but it costs nothing to leave in. // // `optimizeDeps.exclude: ['goods-editor']` — Vite's default is to // prebundle node_modules deps into .vite/deps/ for faster dev // startup. The goods-editor dist is already bundled (it's the // output of Rollup), and prebundling can subtly alter how its // chunks are loaded. Excluding lets Vite serve the dist modules // directly as ESM. // // Dev workflow with git+ssh // ───────────────────────── // Iterating on the module is intentionally heavyweight to mirror // real consumers: // // 1. Make + commit changes in goods-editor-module on `main`. // 2. Run `./scripts/release.sh --version=X.Y.Z` in the module. // This builds, switches to `release` branch, commits dist, // tags `vX.Y.Z`, pushes branch + tag to Gitea. // 3. In this host's package.json, bump the `#vX.Y.Z` fragment // on the goods-editor dep. // 4. `rm -rf node_modules package-lock.json && npm install` // so npm pulls the new tag fresh (just bumping the version // in package.json doesn't always refresh the cloned dep). // 5. `npm run dev` — Vite serves the new dist. // // For faster iteration loops that don't validate the full // install path, use the module's `npm run dev:dist` (smoke // tests the built dist in an isolated browser environment // without needing this host or a release). And `npm run dev` // in the module is the fastest of all — source-aliased dev // host, HMR on source edits, no build needed — useful when // working purely on module internals. resolve: { dedupe: ['react', 'react-dom', 'react/jsx-runtime'], }, optimizeDeps: { exclude: ['goods-editor'], }, 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 — 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 — 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 — stable, every visitor // needs them, perfect for long-term caching. // filerobot/ react-filerobot-image-editor — 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. // goods-editor/ The editor module's own bundle. By far the // largest single piece of UI code (the entire // editor surface). Lives in its own chunk so // host-only deploys don't bust its cache — it // only changes when we bump the module's tag // in package.json. Without this split, it sits // inside `index` and pushes the index chunk // past 2 MiB, which fails the workbox precache // check. // 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 — 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 — if you forget, // the build fails and tells you exactly which chunk overflowed. rollupOptions: { output: { // Rolldown (Vite 8) only accepts a function here — object form was Rollup-only. manualChunks(id) { const groups = [ ['transformers', '@huggingface/transformers'], ['konva', 'konva', 'react-konva', 'use-image'], ['react-vendor', 'react', 'react-dom'], ['filerobot', 'react-filerobot-image-editor'], ['goods-editor', 'goods-editor'], ]; for (const [chunk, ...packages] of groups) { for (const pkg of packages) { if (id.includes(`node_modules/${pkg}/`) || id.includes(`node_modules/${pkg}\\`)) { return chunk; } } } }, }, }, }, });