Files
apparel-designer/vite.config.js
2026-05-23 04:05:03 -05:00

301 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/<filename>` 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/<filename>`); 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
// <img> 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'],
},
},
},
},
});