Files
apparel-designer/vite.config.js
2026-05-24 11:06:10 -05:00

406 lines
20 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/**'],
// 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] },
},
},
// Same-origin path rules below use the full-URL form
// `^https?://[^/]+/<path>/` rather than a path-only `^/<path>/`.
// Workbox matches RegExp urlPatterns against the request's
// full href (e.g. "https://example.com/stickers/cat.png"),
// so a regex anchored with `^/<path>/` would require the URL
// string to literally START with that path — which it never
// does, since the URL always starts with the scheme. The full
// form anchors at the scheme and consumes the origin
// explicitly, then matches the intended pathname.
{
urlPattern: /^https?:\/\/[^/]+\/uploads\//i,
handler: 'CacheFirst',
options: {
cacheName: 'uploaded-images',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 7 },
},
},
{
urlPattern: /^https?:\/\/[^/]+\/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: /^https?:\/\/[^/]+\/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;
}
}
}
},
},
},
},
});